Files
makelore/shared/project-template.ts
2026-07-29 17:22:35 +08:00

938 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 };
}