feat: update Makelore modules and conversations
This commit is contained in:
@@ -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 的 Agent;3D 模型改走项目、用户或许可证明确的外部来源。',
|
||||
['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 资产清单,每项使用稳定 id;Markdown 表格只能作为展示说明,不能替代机器记录', '图片/音频/动画/素材包候选必须先保存项目内可预览证据:至少确认每项的相对 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;
|
||||
}
|
||||
|
||||
134
shared/project-conversations.ts
Normal file
134
shared/project-conversations.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user