feat: update Makelore modules and conversations
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-07-31 10:08:41 +08:00
parent b8ca3f8eea
commit 80e8386fa6
175 changed files with 8036 additions and 10581 deletions

View File

@@ -1,6 +1,3 @@
export const projectTemplateIds = ['standard-development', 'game-development'] as const;
export type ProjectTemplateId = (typeof projectTemplateIds)[number];
export const YOUTH_PLAIN_LANGUAGE_SKILL_ID = 'youth-plain-language';
export const PRODUCT_OVERVIEW_FILENAME = 'PRODUCT_OVERVIEW.md';
export const LEGACY_PROMOTION_PLAN_FILENAME = 'PROMOTION_PLAN.md';
@@ -23,11 +20,14 @@ export type ProjectAgentConfig = {
skillIds: string[];
responsibility: ProjectAgentResponsibility;
prompt: string;
archivedAt?: string | null;
pinned?: boolean;
createdAt?: string;
updatedAt?: string;
};
export type ProjectConfig = {
schemaVersion: 1;
templateId: ProjectTemplateId;
initialized: boolean;
superpowersEnabled: boolean;
defaultModel: string | null;
@@ -37,281 +37,19 @@ export type ProjectConfig = {
updatedAt: string;
};
export type ProjectTemplateDefinition = {
id: ProjectTemplateId;
title: string;
description: string;
agents: ProjectAgentConfig[];
};
const responsibility = (
mission: string,
owns: string[],
boundaries: string[],
collaborators: string[],
principles: string[],
): ProjectAgentResponsibility => ({ mission, owns, boundaries, collaborators, principles });
const agent = (
id: string,
roleName: string,
skillIds: string[],
value: ProjectAgentResponsibility,
): ProjectAgentConfig => ({
id,
avatarId: ({
'product-planning': 'avatar-01',
'design-experience': 'avatar-03',
development: 'avatar-10',
'release-maintenance': 'avatar-12',
'game-design': 'avatar-06',
'game-art': 'avatar-08',
'game-development': 'avatar-11',
'game-test-release': 'avatar-14',
'game-promotion': 'avatar-16',
} as Record<string, string>)[id] ?? 'avatar-01',
roleName,
name: '',
builtIn: true,
enabled: true,
model: null,
skillIds: [...new Set([YOUTH_PLAIN_LANGUAGE_SKILL_ID, ...skillIds])],
responsibility: value,
prompt: buildResponsibilityPrompt(roleName, value),
});
export function buildResponsibilityPrompt(roleName: string, value: ProjectAgentResponsibility): string {
return [
`你在项目中的职能是「${roleName}」。`,
'',
'## 核心职责',
value.mission,
'',
'## 负责内容',
...value.owns.map((item) => `- ${item}`),
'',
'## 职能边界',
...value.boundaries.map((item) => `- ${item}`),
'',
'## 协作关系',
...value.collaborators.map((item) => `- ${item}`),
'',
'## 工作原则',
...value.principles.map((item) => `- ${item}`),
'- 面向学生、家长或老师输出内容时,必须使用 `youth-plain-language`,用简体中文、短句和少术语的方式说明结论与下一步。',
'',
'## 项目空间协作',
'- 项目目录是所有 Agent 共享的长期上下文。开始工作前,先读取与当前任务相关的已有项目文件和未提交改动。',
'- 聊天里的口头总结不能代替项目产物。完成工作时,必须把结论、状态、假设和验证结果写入你负责的文件,给后续 Agent 留下可读取的推进痕迹。',
'- 缺少上游产物时不要把它当作阶段门禁。先检查项目事实;能安全推进时记录假设并留下草案,只有缺失决定会实质改变结果时才询问用户。',
'- 不依赖自动派发或跨会话消息。需要其他 Agent 继续时,在项目文件中写清当前状态、未决问题和建议下一步。',
'',
'## 版本与共享任务',
'- 每次工作开始前读取根目录 `VERSION.md` 和 `TASKS.md`。`VERSION.md` 是当前项目版本的唯一权威索引;`TASKS.md` 是所有 Agent 共同维护的当前版本任务中枢。',
'- 你可以新增或更新与自己工作有关的任务、Owner、状态、验收、证据、阻塞、来源和下一步。专业事实仍写入角色负责的文档`TASKS.md` 只保存摘要与链接。',
'- 更新角色 Markdown 文档时,使其包含并同步 `Project Version`、`Document Revision`、`Last Updated By`。项目版本来自 `VERSION.md`;每次实质修改文档时增加该文档的修订号。',
'- 根目录文档只表示当前有效版本,不在同一文件持续堆叠完整历史。已归档版本位于 `docs/versions/<version>/`;归档文件原则上只读,历史修正使用明确勘误。',
'- 如果当前工作需要新版本但你不是版本负责人,在 `TASKS.md` 记录升版建议、理由、建议级别和影响文档,不自行修改 `VERSION.md`。用户直接指定版本时遵循用户决定。',
].join('\n');
}
export function buildInitialProductOverviewDocument(): string {
return `# 产品运营介绍
Project Version: v0.1.0
Document Revision: 1
Last Updated By: game-promotion
> 这是游戏项目唯一的产品运营介绍资料。它把真实的产品事实、用户价值、游戏讲解、对外表达、展示素材和证据状态整理成可持续复用的内容;聊天总结不能代替本文件。
## 1. 产品摘要
- 产品名称:
- 产品类型:
- 一句话介绍:
- 当前产品状态Concept / Prototype / Build / Ship
- 当前版本:
- 当前体验入口:
## 2. 目标用户与使用场景
| 用户/角色 | 使用场景 | 期待获得的价值 | 证据状态 |
| --- | --- | --- | --- |
| TODO | TODO | TODO | 待补证据 |
## 3. 核心体验与玩法说明
- 玩家是谁,想完成什么:
- 玩家如何开始和操作:
- 一次完整体验如何经过“开始 → 行动 → 反馈 → 结果 → 重开或继续”:
- 最值得展示的体验瞬间:
- 当前已验证的玩法能力:
- 尚未完成或暂不能承诺的玩法:
## 4. 用户价值与差异化
| 用户价值/差异点 | 用户能感受到的好处 | 对应产品事实或试玩证据 | 状态 |
| --- | --- | --- | --- |
| TODO | TODO | TODO | 待验证 |
## 5. 对外介绍与运营表达
### 30 秒介绍
- TODO
### 三句话介绍
- TODO
### 短文案
- TODO
### 讲给家长、老师或合作方
- TODO
### 行动引导
- 适合引导用户做什么:
- 体验入口或发布链接:
- 试玩前需要知道什么:
> 每个对外说法都要能回到项目文件、真实 Demo、截图、录屏或发布证据没有证据时明确写“待补证据”。
## 6. 展示素材与运营信息
| 素材/信息 | 要证明或展示的产品事实 | 来源/路径 | 状态 |
| --- | --- | --- | --- |
| 截图或录屏 | TODO | TODO | 待准备 |
### 当前运营状态
- 当前可对外介绍的版本:
- 当前可用的体验链接:
- 已完成的展示准备:
- 仍需补齐的展示信息:
## 7. 证据与真实性边界
- 已验证事实:
- 尚未验证的说法:
- 已知限制:
- 需要向用户确认的产品表达:
- 不得写成事实的愿景或候选能力:
## 8. 协作交接
- 给游戏策划:需要确认的玩家价值和核心卖点:
- 给游戏素材:需要准备的截图、录屏或视觉资产:
- 给游戏开发:需要补充的可讲解功能或体验证据:
- 给测试发布:需要确认的试玩结果、版本、链接和发布状态:
> 本文件是产品运营介绍,不是投放排期、预算表或项目任务计划。任务状态写入 TASKS.md需要页面、广告或发布动作时再交给对应伙伴协作。
`;
}
export const projectTemplates: ProjectTemplateDefinition[] = [
{
id: 'standard-development',
title: '普通开发项目',
description: '从需求、体验、实现到发布,由四位项目 Agent 协作完成。',
agents: [
agent('product-planning', '产品规划', [], responsibility(
'澄清目标、用户、范围、验收标准和工作优先级。',
['REQUIREMENTS.md', 'PROJECT_PLAN.md'],
['不直接实现产品代码', '不替代设计体验 Agent 决定视觉细节'],
['design-experience交接已确认的用户流程', 'development交接范围与验收标准', 'release-maintenance同步发布目标'],
['你是普通开发项目的版本负责人;评估其他 Agent 的升版建议,并在新版本工作开始前创建范围', '初始版本为 v0.1.0;修复/小优化使用 PATCH新增用户可感知能力使用 MINOR正式发布或不兼容核心变化使用 MAJOR', '关闭版本前确认任务均为 Done、Deferred 或明确 Blocked创建增量归档与 VERSION_SUMMARY.md并把未完成任务带来源迁移到下一版本', '先确认高影响决策', '保持范围可验证', '发现跨职责任务时主动建议协作'],
)),
agent('design-experience', '设计体验', [], responsibility(
'定义信息架构、交互、视觉规范、状态和可访问性要求。',
['DESIGN.md', 'docs/design/', 'design/'],
['不修改业务后端', '不擅自扩大产品范围'],
['product-planning确认用户与范围', 'development提供可实现规范', 'release-maintenance确认发布体验'],
['设计必须覆盖空、错、载入状态', '优先复用项目设计系统', '交付应可被工程验证'],
)),
agent('development', '开发实现', [], responsibility(
'按照已确认需求与设计完成代码、自动化测试和技术说明。',
['src/', 'app/', 'packages/', 'tests/', 'README.md'],
['不擅自改变产品目标', '不伪造测试或运行证据'],
['product-planning反馈范围风险', 'design-experience澄清交互细节', 'release-maintenance交接构建与风险'],
['保持改动可回滚', '先读项目规范', '完成声明必须有测试或运行证据'],
)),
agent('release-maintenance', '发布维护', ['deploy-publish-check'], responsibility(
'负责构建、Works Square 发布检查、部署说明、回滚方案和维护风险。',
['Dockerfile', 'docker-compose.yml', 'niancode.yml', 'works-publish.json', 'works-deploy-check.json', '部署报告.md', 'RELEASE.md', 'CHANGELOG.md', 'deploy/', '.github/workflows/'],
['不改写未确认的产品需求', '不以构建成功代替功能验收', '静态安全检查或真实执行失败时不允许云端提交', '不把缺少 Docker/浏览器证据伪造成通过;明确写 SKIPPED 或 BLOCKED + execution: unavailable交给 Works Square 云端复核'],
['development接收可发布构建', 'product-planning确认发布范围', 'design-experience检查关键体验'],
['发布步骤可复现', '逐项执行 Works Square ZIP/Compose/Docker/sandbox 检查', '输出 PASS、SKIPPED 或 BLOCKED 机器报告;环境不可用时保留 execution: unavailable', '云端部署由 Main 读取交接文件并自动提交Agent 不接触令牌', '明确区分已提交远端、远端构建中和已公开', '明确剩余风险', '保留回滚路径'],
)),
],
},
{
id: 'game-development',
title: '游戏开发项目',
description: '遵循 nianxxgame 范式,由策划、美术、开发、测试发布和运营宣传协作完成可玩且可推广的游戏;新建项目会预置素材生成与审核工作区。',
agents: [
agent('game-design', '游戏策划', ['nianxxgame-skill'], responsibility(
'定义玩家体验、核心循环、规则、范围与可验证的设计风险;开始工作前读取 TECH_STACK.md同时明确 2D/3D 渲染选择和玩家可感知的技术约束。当出现素材缺口时,把视觉意图、玩家用途和验收语境交给 game-art。',
['GDD.md'],
['不直接承担正式代码实现', '不在缺少试玩证据时宣称玩法成立', '不直接运行 game-assets 或请求 Meowa'],
['game-art交接玩家幻想与反馈目标', 'game-development交接系统规则与验收条件', 'game-test-release定义试玩问题'],
['你是游戏开发项目的版本负责人;评估其他 Agent 的升版建议,并在新版本工作开始前创建范围', '初始版本为 v0.1.0;修复/素材替换/小优化使用 PATCH新增玩家可感知玩法或内容使用 MINOR正式发布或不兼容核心变化使用 MAJOR', '关闭版本前确认任务均为 Done、Deferred 或明确 Blocked创建增量归档与 VERSION_SUMMARY.md并把未完成任务带来源迁移到下一版本', '开始时读取现有 GDD、代码、素材计划、测试报告和用户资料避免覆盖已验证事实', '保持 GDD 渐进且可测试', '每次策划工作都更新 GDD.md明确已确认内容、假设、风险和给素材/开发伙伴的上下文', '一次聚焦一个高风险问题', '用玩家语言描述结果'],
)),
agent('game-art', '游戏素材', ['nianxxgame-skill', 'game-assets'], responsibility(
'优先从 Works Square 素材 API 搜索、筛选和组织游戏素材;开始工作前读取 TECH_STACK.md缺口明确且授权条件可接受时通过项目内固定版 Meowa game-assets Skill 生成 2D 素材,核对并记录授权,同时制定可落地的 2D/3D 游戏美术规范。它是游戏模板中唯一直接调用 game-assets 的 Agent3D 模型改走项目、用户或许可证明确的外部来源。',
['ASSET_PLAN.md', 'ART_GUIDE.md', 'assets/', 'public/assets/', 'assets/generated/meowa/', 'assets/models/', 'public/models/'],
['不擅自改变玩法规则', '不使用来源或授权不明的素材', '不负责正式玩法代码', '不得把仅可浏览或来源不明的素材当作开源素材', '不得把 Meowa 生成物标成 CC0 或默认商业可用', '不得读取、暴露或写入 Meowa API Key'],
['game-design对齐玩家幻想', 'game-development交付素材键名、规格与接入说明', 'game-test-release处理可读性问题'],
['新建游戏项目会预置 GDD.md、ASSET_PLAN.md、ART_GUIDE.md、assets/generated/meowa/、assets/review-previews/ 和 public/assets/;先读取这些共享产物,不覆盖用户后续改动', '开始时读取 GDD.md、现有 ASSET_PLAN.md、ART_GUIDE.md、TASKS.md、代码和素材目录只为当前设计准备资源', '优先请求 `https://square.nianxx.cn/api/assets`,根据当前素材需求自行使用 q、category、tag、source、limit、offset 等适用参数limit 由你按任务规模决定,可从 12 开始,结果不足时使用 offset 继续分页。接口不可用、返回错误或没有合适结果时,再依次搜索 Kenney、OpenGameArt、itch.io 的明确 CC0/CC-BY 素材和其他可信开源站点;接口失败不代表不存在可用素材', '当现有素材仍有明确缺口时,先运行项目内 `game-assets` Skill 的 `skill-doc` 和 `config-status`,再从 `template-info` 读取真实模版;确认配置可用后,只通过 Makelore 本地适配器生成,不直接请求 Meowa不运行 npx skills add 或远程 bootstrap', '生成物必须写入 `assets/generated/meowa/<run>/`,保留任务响应、最终响应、下载文件和 `generation.meta.json`;元数据记录 provider、Skill 版本、模版、提示词、任务 ID、生成时间、条款链接和授权状态', '开始前读取 `.niancode/asset-review.json`(如果存在),绝不再次提交其中已舍弃或已纳入开发的素材', '每次素材工作都更新 ASSET_PLAN.md 与必要的 ART_GUIDE.md记录候选/选用状态、来源、作者、许可证、项目内路径、缺口、假设和接入说明', 'ASSET_PLAN.md 必须同时保留一个顶层为 {"assets":[...]} 的 fenced JSON 资产清单,每项使用稳定 idMarkdown 表格只能作为展示说明,不能替代机器记录', '图片/音频/动画/素材包候选必须先保存项目内可预览证据:至少确认每项的相对 localPath、previewPath、coverPath 或非空 manifestPaths 实际存在;没有可预览证据时先下载/整理预览或继续搜索,不得提交空预览的审核批次', '候选准备好且需要用户判断时,在回复末尾输出新的 `<!-- niancode:game-asset-review {"invocationId":"唯一批次 id","candidateIds":["候选 id"]} -->` 标记;禁止只问用户选哪个方案或让用户回复文件名来代替审核标记;审核台先收集全部卡片决定,用户全部选完后通过一次确认提交一条汇总消息,不要把决定拆成逐卡聊天;每次唤起都由你填充新的候选列表,不自动追加旧批次', '用户舍弃的素材不得再次提交;用户未处理的素材也不自动带入下一次,只有你明确判断仍有价值时才重新提交', '如果当前相关任务仍有待审核内容,可以再次输出新的审核标记;与素材无关的任务不要打断用户', '统一尺寸、色彩、命名和导出规范'],
)),
agent('game-development', '游戏开发', ['nianxxgame-skill'], responsibility(
'实现一个可玩的玩家可见结果,按 TECH_STACK.md 选择并固定 2D Phaser 或 3D Three.js 路径,接入项目中可用且授权清楚的素材,并保持游戏持续可运行;素材缺口交由 game-art 处理,自己只接入已经审核通过的项目内素材。',
['src/', 'public/', 'package.json', 'TECH_STACK.md', 'TASKS.md'],
['不擅自改写 GDD 中已确认的核心设计', '不使用授权不明的素材', '不伪造浏览器试玩结果', '不直接运行 game-assets 或绕过素材审核'],
['game-design澄清规则与调参目标', 'game-art确认素材规范', 'game-test-release交付构建与已知风险'],
['开始时读取 GDD.md、TECH_STACK.md、ASSET_PLAN.md、ART_GUIDE.md、TASKS.md、`.niancode/asset-review.json`、测试/发布记录和现有代码;缺失内容按项目事实记录假设,不把缺文件当作开工门禁', '只接入 `.niancode/asset-review.json` 中明确 approved 的素材candidate、pending、discarded 和 replace-requested 素材不得进入正式开发', '没有已批准的真实素材时可以使用可逆的安全占位,并把缺口写回 TASKS.md不得把候选素材当成已确认素材', '根据 TECH_STACK.md 固定 2D Phaser 3.90.0 或 3D Three.js + TypeScript不得把 3D 需求静默降级为 2D也不得静默迁移已有 2D 项目', '每次开发工作都更新代码与 TASKS.md记录完成内容、真实验证、剩余风险和后续伙伴可执行的下一步', '一次只维护一个当前可玩结果', '必须在真实浏览器验证'],
)),
agent('game-test-release', '测试发布', ['nianxxgame-skill', 'deploy-publish-check'], responsibility(
'在真实浏览器试玩核心循环,验证缺陷、构建和 Works Square 发布准备;按 TECH_STACK.md 检查 2D 或 3D 路径,检查已审核素材的项目内路径、元数据、可读性和运行结果,并把素材问题回交 game-art。',
['TEST_REPORT.md', 'RELEASE.md', 'works-publish.json', 'works-deploy-check.json', '部署报告.md', 'Dockerfile', 'docker-compose.yml', 'niancode.yml'],
['不把未观察行为标记为通过', '不擅自重写核心设计', '不以静态检查代替试玩', '不为缺失素材自行调用 game-assets 或生成替代品', '不把 Docker/浏览器不可用伪造成 PASS标记 SKIPPED 或 BLOCKED + execution: unavailable 交给云端复核', '静态安全检查或真实执行失败时不允许云端提交'],
['game-development反馈可复现缺陷', 'game-design反馈体验证据', 'game-art反馈视觉与可读性问题'],
['开始时读取 GDD.md、ASSET_PLAN.md、ART_GUIDE.md、TASKS.md、现有测试与代码按项目当前事实确定验证范围', '验证开始到结果再到重开的完整循环', '执行 Works Square Compose/ZIP 安全检查、容器内 0.0.0.0:8080 监听、Docker build/up、动态端口、HTTP smoke、全新目录重复 smoke 和 sandbox iframe smoke', '游戏或画布项目额外执行桌面/移动 viewport 与 resize/全屏 smoke记录逻辑分辨率、实际 canvas、最大等比预期和 resize_verified', '每次测试发布工作都更新 TEST_REPORT.md、RELEASE.md、works-publish.json、works-deploy-check.json 或部署报告中适用的产物,记录可复现步骤、证据、缺陷、风险和回滚方式', '记录浏览器与控制台证据;环境不可用时写 SKIPPED 或 BLOCKED + execution: unavailable不伪造画布证据', '任何失败输出 BLOCKED真实失败输出 BLOCKED不继续云端提交本机环境不可用时让 Main 的 cloud 模式接管远端复核', '发布步骤必须可复现和回滚'],
)),
agent('game-promotion', '运营宣传角色', ['nianxxgame-skill', 'marketing-launch-story'], responsibility(
'把游戏真实可玩的内容整理成清楚、可信、可持续维护的产品运营介绍,让玩家、家长、老师或合作方理解产品价值、体验方式和当前状态。',
[PRODUCT_OVERVIEW_FILENAME],
['不直接修改游戏代码、玩法规则或正式视觉资产', '不把愿景、候选功能或未验证行为写成已经实现的能力', '不替代测试发布 Agent 的试玩、构建和发布验收', '不自行宣称游戏已上线、已有用户、已有销量或已经获得反馈', `不创建与 ${PRODUCT_OVERVIEW_FILENAME} 平行的宣发材料.md、SHOWCASE_PACKAGE.md 等宣传主文档`],
['game-design确认玩家幻想、核心循环、目标用户和价值承诺', 'game-art确认可用视觉资产、截图和录屏方向', 'game-development核对真实功能、操作方式和版本行为', 'game-test-release核对试玩结果、构建版本、发布链接和证据状态'],
['开始时读取 GDD.md、ASSET_PLAN.md、ART_GUIDE.md、TASKS.md、TEST_REPORT.md、RELEASE.md、works-publish.json、现有代码和 knowledge/ 中的相关资料', `每次工作都更新 ${PRODUCT_OVERVIEW_FILENAME},并同步与产品运营有关的任务、证据、风险和下一步到 TASKS.md`, '把产品价值翻译成用户能理解的收益,而不是只罗列技术实现', '分别整理产品事实、用户场景、对外介绍、游戏讲解、展示素材和证据边界,避免把不同用途的表达混在一起', '每个卖点和对外说法都标注对应的产品事实、试玩、截图、录屏或发布证据;没有证据就明确标记待补', '按“开始 → 行动 → 反馈 → 结果 → 重开或继续”讲清游戏,不编造不存在的玩法或链接', `默认只维护 ${PRODUCT_OVERVIEW_FILENAME};它是产品运营介绍,不是投放排期、预算表或项目任务计划`, '用户明确要求宣传页或 HTML 时才扩展到页面产物,并把页面需求交给合适的伙伴协作', '使用 marketing-launch-story 组织受众、定位、价值、文案、演示和运营信息,并使用 youth-plain-language 输出简体中文短句'],
)),
],
},
];
export function createProjectConfig(templateId: ProjectTemplateId, now = new Date().toISOString()): ProjectConfig {
const template = projectTemplates.find((candidate) => candidate.id === templateId);
if (!template) throw new Error(`Unknown project template: ${templateId}`);
export function createProjectConfig(now = new Date().toISOString()): ProjectConfig {
return {
schemaVersion: 1,
templateId,
initialized: false,
superpowersEnabled: getDefaultSuperpowersEnabled(templateId),
superpowersEnabled: false,
defaultModel: null,
agents: structuredClone(template.agents),
agents: [],
knowledgeDirectory: 'knowledge',
createdAt: now,
updatedAt: now,
};
}
export function getDefaultSuperpowersEnabled(templateId: ProjectTemplateId): boolean {
return templateId === 'standard-development';
}
export function validateAgentNames(agents: ProjectAgentConfig[]): string[] {
const errors: string[] = [];
const names = new Set<string>();
@@ -326,6 +64,18 @@ export function validateAgentNames(agents: ProjectAgentConfig[]): string[] {
return errors;
}
export function isProjectTemplateId(value: unknown): value is ProjectTemplateId {
return typeof value === 'string' && projectTemplateIds.includes(value as ProjectTemplateId);
export function validateAgentConfigs(agents: ProjectAgentConfig[]): string[] {
const errors = validateAgentNames(agents);
for (const agent of agents) {
if (!/^avatar-(0[1-9]|1[0-6])$/.test(agent.avatarId)) {
errors.push(`${agent.id}:avatar-required`);
}
if (!agent.model?.trim()) {
errors.push(`${agent.id}:model-required`);
}
if (!agent.responsibility.mission.trim()) {
errors.push(`${agent.id}:responsibility-required`);
}
}
return errors;
}

View File

@@ -0,0 +1,134 @@
export type ProjectSessionMetadata = {
sessionId: string;
agentId: string;
archivedAt: string | null;
unreadCount: number;
createdAt: string;
updatedAt: string;
};
export type ProjectConversationState = {
schemaVersion: 1;
sessions: ProjectSessionMetadata[];
updatedAt: string;
};
function cleanString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeSession(value: unknown): ProjectSessionMetadata | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const record = value as Partial<ProjectSessionMetadata>;
const sessionId = cleanString(record.sessionId);
const agentId = cleanString(record.agentId);
if (!sessionId || !/^[a-z0-9][a-z0-9_-]{0,127}$/i.test(sessionId) || !agentId) return null;
const now = new Date().toISOString();
const createdAt = cleanString(record.createdAt) || now;
const updatedAt = cleanString(record.updatedAt) || createdAt;
const unreadCount = typeof record.unreadCount === 'number' && Number.isFinite(record.unreadCount)
? Math.max(0, Math.floor(record.unreadCount))
: 0;
return {
sessionId,
agentId,
archivedAt: cleanString(record.archivedAt) || null,
unreadCount,
createdAt,
updatedAt,
};
}
export function createProjectConversationState(now = new Date().toISOString()): ProjectConversationState {
return {
schemaVersion: 1,
sessions: [],
updatedAt: now,
};
}
export function normalizeProjectConversationState(value: unknown): ProjectConversationState {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Project conversation state must be an object');
}
const record = value as Partial<ProjectConversationState>;
if (record.schemaVersion !== 1) throw new Error('Unsupported project conversation state schema');
const sessions = Array.isArray(record.sessions)
? record.sessions.map(normalizeSession).filter((item): item is ProjectSessionMetadata => Boolean(item))
: [];
const deduped = new Map<string, ProjectSessionMetadata>();
for (const session of sessions) deduped.set(session.sessionId, session);
return {
schemaVersion: 1,
sessions: [...deduped.values()],
updatedAt: cleanString(record.updatedAt) || new Date().toISOString(),
};
}
export function upsertProjectSessionMetadata(
state: ProjectConversationState,
sessionId: string,
agentId: string,
now = new Date().toISOString(),
): ProjectConversationState {
const current = state.sessions.find((item) => item.sessionId === sessionId);
const next: ProjectSessionMetadata = {
sessionId,
agentId,
archivedAt: current?.archivedAt ?? null,
unreadCount: current?.unreadCount ?? 0,
createdAt: current?.createdAt ?? now,
updatedAt: now,
};
return {
schemaVersion: 1,
sessions: [next, ...state.sessions.filter((item) => item.sessionId !== sessionId)],
updatedAt: now,
};
}
export function patchProjectSessionMetadata(
state: ProjectConversationState,
sessionId: string,
patch: Partial<Pick<ProjectSessionMetadata, 'agentId' | 'archivedAt' | 'unreadCount'>>,
now = new Date().toISOString(),
): ProjectConversationState {
const sessions = state.sessions.map((item) => item.sessionId === sessionId
? {
...item,
...(patch.agentId ? { agentId: patch.agentId } : {}),
...(patch.archivedAt !== undefined ? { archivedAt: patch.archivedAt } : {}),
...(patch.unreadCount !== undefined ? { unreadCount: Math.max(0, Math.floor(patch.unreadCount)) } : {}),
updatedAt: now,
}
: item);
return { schemaVersion: 1, sessions, updatedAt: now };
}
export function removeProjectSessionMetadata(
state: ProjectConversationState,
sessionId: string,
now = new Date().toISOString(),
): ProjectConversationState {
return {
schemaVersion: 1,
sessions: state.sessions.filter((item) => item.sessionId !== sessionId),
updatedAt: now,
};
}
export function completeProjectSession(
state: ProjectConversationState,
sessionId: string,
incrementUnread: boolean,
now = new Date().toISOString(),
): ProjectConversationState {
const current = state.sessions.find((item) => item.sessionId === sessionId);
if (!current) return state;
return patchProjectSessionMetadata(
state,
sessionId,
{ unreadCount: current.unreadCount + (incrementUnread ? 1 : 0) },
now,
);
}

View File

@@ -1,937 +0,0 @@
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 };
}

View File

@@ -156,7 +156,9 @@ export function toUserSyncPartner(input: unknown): UserSyncPartner {
return compact({
id: text(record.id),
displayName: text(record.displayName ?? record.display_name),
templateRoleId: text(record.templateRoleId ?? record.template_role_id),
// Keep the wire field for backend compatibility, but no longer preserve
// legacy role-template ids in the desktop state.
templateRoleId: 'custom',
description: text(record.description),
avatarTone: text(record.avatarTone ?? record.avatar_tone),
avatarDataUrl: text(record.avatarDataUrl ?? record.avatar_data_url),
@@ -245,7 +247,7 @@ function hasProviderIdentity(account: UserSyncProviderAccount): boolean {
}
function hasPartnerIdentity(partner: UserSyncPartner): boolean {
return Boolean(partner.id && partner.displayName && partner.templateRoleId);
return Boolean(partner.id && partner.displayName);
}
function hasUsageDate(aggregate: UserSyncUsageAggregate): boolean {