Files
makelore/electron/opencode/project-config.ts
2026-07-29 17:22:35 +08:00

341 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import path from 'node:path';
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
import {
createProjectConfig,
buildResponsibilityPrompt,
getDefaultSuperpowersEnabled,
isProjectTemplateId,
projectTemplates,
YOUTH_PLAIN_LANGUAGE_SKILL_ID,
validateAgentNames,
type ProjectAgentConfig,
type ProjectConfig,
type ProjectTemplateId,
} from '../../shared/project-config';
export const PROJECT_CONFIG_PATH = '.niancode/project.json';
function versionOwnerForTemplate(templateId: ProjectTemplateId): string {
if (templateId === 'game-development') return 'game-design';
return 'product-planning';
}
function initialVersionDocument(templateId: ProjectTemplateId, now: string): string {
const owner = versionOwnerForTemplate(templateId);
return `# Project Version
Current: v0.1.0
Status: active
Version Owner: ${owner}
Started At: ${now}
Task Plan: TASKS.md
Summary: docs/versions/v0.1.0/VERSION_SUMMARY.md
Previous: none
`;
}
function initialTaskDocument(templateId: ProjectTemplateId): string {
const owner = versionOwnerForTemplate(templateId);
const isGameTemplate = templateId === 'game-development';
const versionGoal = isGameTemplate
? '建立一个可试玩的最小游戏循环,并完成第一批有来源、可预览、可审核的游戏素材候选。'
: '定义 v0.1.0 的最小可验证目标。';
const nowTasks = isGameTemplate
? `- [ ] TASK-001 明确当前版本目标
- Owner: ${owner}
- Status: todo
- Acceptance: GDD.md 写清玩家目标、核心循环、首个可试玩结果和当前边界
- Evidence: pending
- [ ] TASK-002 建立素材清单和接入策略
- Owner: game-art
- Status: todo
- Acceptance: ASSET_PLAN.md、ART_GUIDE.md 和素材目录已准备;每个缺口都有来源、授权、预览和审核证据规则
- Evidence: pending
- [ ] TASK-003 确认 2D/3D 渲染路径
- Owner: game-development
- Status: todo
- Acceptance: TECH_STACK.md 写清 2D Phaser 3.90.0 或 3D Three.js + TypeScript、浏览器目标和首个可玩验证方式
- Evidence: pending`
: `- [ ] TASK-001 明确当前版本目标
- Owner: ${owner}
- Status: todo
- Acceptance: 当前版本范围、验收和受影响文档已写清
- Evidence: pending`;
return `# TASKS
Project Version: v0.1.0
Document Revision: 1
Last Updated By: ${owner}
> 所有 Agent 共同读取和维护本文件。专业事实写入角色文档这里保留任务状态、Owner、验收、证据摘要、阻塞和链接。
## Version Goal
- ${versionGoal}
## Now
${nowTasks}
## Next
- None
## Blocked
- None
## Version Proposals
- None
## Done
- None
`;
}
const GAME_PROJECT_DIRECTORIES = [
'assets/generated/meowa',
'assets/models',
'assets/review-previews',
'public/assets',
'public/models',
] as const;
const MARKDOWN_FENCE = '```';
function buildGameProjectDocuments(): Record<string, string> {
const header = (title: string, owner: string) => `# ${title}\n\nProject Version: v0.1.0\nDocument Revision: 1\nLast Updated By: ${owner}\n\n`;
return {
'GDD.md': `${header('游戏设计', 'game-design')}## 当前目标\n\n- 玩家目标:待确认\n- 核心循环:待确认\n- 首个可试玩结果:待确认\n- 渲染模式2D默认或 3D用户明确选择时\n- 技术方案待确认2D 使用 Phaser 3.90.03D 使用 Three.js + TypeScript\n- 当前版本边界:只推进一个可验证的玩家体验增量。\n\n## 已确认事实\n\n- 待补充。\n\n## 设计假设与风险\n\n- 假设:待补充。\n- 风险:待补充。\n\n## 交接\n\n- 给 game-art玩家幻想、反馈目标、渲染模式下需要表达的视觉信息待补充。\n- 给 game-development规则、输入、状态变化、渲染模式和验收条件待补充。\n- 给 game-test-release核心循环、目标浏览器和真实试玩路径待补充。\n`,
'TECH_STACK.md': `${header('游戏技术方案', 'game-development')}## 选择规则\n\n- 渲染模式2D默认或 3D用户明确选择时\n- 2D 引擎Phaser 3.90.0,严格固定版本。\n- 3D 引擎Three.js + TypeScript必须记录精确的 three 版本和 lockfile。\n- 当前选择2D / Phaser 3.90.0(待确认)。\n\n## 3D 路径边界\n\n- 用户明确选择 3D 后先更新本文件、GDD.md 和 TASKS.md再开始实现不得把 3D 需求静默降级成 2D也不得把已有 2D 项目静默迁移成 3D。\n- 3D 首个结果必须包含真实浏览器中的场景、相机、一个可操作对象、反馈和重开/继续路径。\n- 3D 模型优先使用 GLB/glTF模型、纹理、动画和授权记录保存在项目内。\n- Meowa game-assets 当前只生成 2D 像素/高清图3D 模型不走 Meowa 生成链路。\n\n## 浏览器与验收\n\n- 目标浏览器和桌面/移动视口:待确认。\n- 3D 需要记录相机、灯光、加载失败、resize、device-pixel-ratio 和性能证据。\n- 只使用项目包管理器和本地资源;不依赖 CDN-only runtime。\n`,
'ASSET_PLAN.md': `${header('游戏素材计划', 'game-art')}## 使用顺序\n\n1. 先搜索 Works Square。\n2. Works Square 不可用或没有合适结果时,再找许可证明确的开源来源。\n3. 2D 缺口仍明确时,使用项目内固定版 game-assets Skill通过 Main 代理调用 Meowa。\n4. 3D 模型缺口使用用户提供、项目内已有或许可证明确的 GLB/glTF 等来源;不把 2D 生成图伪装成 3D 模型。\n\n## 2D/3D 资产边界\n\n- Meowa game-assets 当前只负责 2D 像素/高清光栅素材2D 纹理、UI 和参考图仍需经过相同的预览、授权和审核流程。\n- 3D 模型、骨骼动画、材质和环境资源记录在 assets/models/ 或 public/models/,优先使用 GLB/glTF并记录来源、作者、许可证、格式、版本和预览。\n- 生成物只能写入 assets/generated/meowa/<run>/3D 外部来源必须先复制或整理到项目内,再进入审核。\n\n## 生成规则\n\n- 开始前读取 GDD.md、TECH_STACK.md、ART_GUIDE.md、TASKS.md 和 .niancode/asset-review.json如果存在\n- 生成前运行 skill-doc、config-status 和 template-info确认凭据和真实模版可用后再提交生成。\n- 生成物只能写入 assets/generated/meowa/<run>/,并保留任务响应、最终响应、下载文件和 generation.meta.json。\n- 元数据必须记录 provider、Skill 版本、模板、提示词、任务 ID、生成时间、条款链接和授权状态。\n- 生成物先作为 candidate 保存预览用户明确审核通过后game-development 才能接入 public/assets/ 或 public/models/。\n- Meowa 生成物不能默认标成 CC0 或商业可用授权不清、3D 模型来源不清或没有预览证据时停止并记录阻塞。\n\n## 机器可核验资产清单\n\n${MARKDOWN_FENCE}json\n{\n "schemaVersion": 1,\n "selectionStatus": "draft",\n "selectionDecision": "pending",\n "confirmedAssetIds": [],\n "unresolvedRequiredAssetIds": [],\n "assets": []\n}\n${MARKDOWN_FENCE}\n\n> fenced JSON 是唯一机器记录Markdown 表格只用于给人阅读,不能替代资产清单。\n\n## 候选与接入记录\n\n| ID | 用途 | 类型 | 来源/Provider | 项目内路径 | 许可证/授权状态 | 预览证据 | 状态 |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n| 待补充 | 待补充 | 2D/3D | 待补充 | 待补充 | 待核对 | 待补充 | draft |\n\n## 审核与交接\n\n- 审核批次:待生成。\n- 用户决定:待确认。\n- 接入说明:待补充。\n`,
'ART_GUIDE.md': `${header('游戏美术规范', 'game-art')}## 视觉目标\n\n- 风格:与 GDD.md 中已确认的玩家幻想和渲染模式一致,待补充。\n- 画面重点:让玩家一眼看懂角色、危险、目标和反馈。\n- 资产之间保持一致的比例、视角、轮廓、光照、色彩和命名。\n\n## 2D 规格\n\n- 类型sprite、tileset、背景、UI、特效或音频按 ASSET_PLAN.md 逐项确认。\n- 尺寸、透明背景、锚点、帧数、色板和导出格式:每次接入前明确记录。\n- 像素素材使用 nearest-neighbor高清素材保留原始尺寸和可追溯预览。\n\n## 3D 规格\n\n- 模型格式优先 GLB/glTF记录坐标系、单位、原点、朝向、骨骼/动画、材质、纹理、面数和文件大小。\n- 记录相机、灯光、阴影、透明度、碰撞体和加载失败时的占位方案。\n- 3D 模型不由 Meowa game-assets 生成;必须有项目内路径、预览、来源和授权状态。\n\n## 生成与验收\n\n- 先查已有项目素材和 Works Square再处理明确缺口。\n- 使用 game-assets Skill 时保留 generation.meta.json不在 Agent 中暴露 Key 或直接访问 Meowa。\n- 每个候选必须有项目内可预览证据、来源和授权状态;未审核候选不得替代正式资产。\n- 正式接入只使用审核状态为 approved 的项目内文件。\n`,
};
}
export type ProjectConfigReadResult =
| { status: 'valid'; config: ProjectConfig }
| { status: 'missing' }
| { status: 'invalid'; error: string };
function configPath(projectPath: string): string {
return path.join(projectPath, PROJECT_CONFIG_PATH);
}
function normalizeStringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return [...new Set(value.filter((item): item is string => typeof item === 'string').map((item) => item.trim()).filter(Boolean))];
}
function normalizeAgent(value: unknown): ProjectAgentConfig | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const raw = value as Partial<ProjectAgentConfig>;
const id = typeof raw.id === 'string' ? raw.id.trim() : '';
const name = typeof raw.name === 'string' ? raw.name.trim() : '';
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(id)) return null;
const responsibility = raw.responsibility;
if (!responsibility || typeof responsibility !== 'object') return null;
return {
id,
avatarId: typeof raw.avatarId === 'string' && /^avatar-(0[1-9]|1[0-6])$/.test(raw.avatarId) ? raw.avatarId : 'avatar-01',
roleName: typeof raw.roleName === 'string' && raw.roleName.trim() ? raw.roleName.trim() : '项目伙伴',
name,
builtIn: raw.builtIn === true,
enabled: true,
model: typeof raw.model === 'string' && raw.model.trim() ? raw.model.trim() : null,
skillIds: [...new Set([YOUTH_PLAIN_LANGUAGE_SKILL_ID, ...normalizeStringList(raw.skillIds)])],
responsibility: {
mission: typeof responsibility.mission === 'string' ? responsibility.mission.trim() : '',
owns: normalizeStringList(responsibility.owns),
boundaries: normalizeStringList(responsibility.boundaries),
collaborators: normalizeStringList(responsibility.collaborators),
principles: normalizeStringList(responsibility.principles),
},
prompt: typeof raw.prompt === 'string' && raw.prompt.trim()
? raw.prompt.trim()
: buildResponsibilityPrompt(
typeof raw.roleName === 'string' && raw.roleName.trim() ? raw.roleName.trim() : '项目伙伴',
{
mission: typeof responsibility.mission === 'string' ? responsibility.mission.trim() : '',
owns: normalizeStringList(responsibility.owns),
boundaries: normalizeStringList(responsibility.boundaries),
collaborators: normalizeStringList(responsibility.collaborators),
principles: normalizeStringList(responsibility.principles),
},
),
};
}
export function normalizeProjectConfig(value: unknown): ProjectConfig {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Project config must be an object');
const raw = value as Partial<ProjectConfig>;
if (raw.schemaVersion !== 1) throw new Error('Unsupported project config schema');
if (!isProjectTemplateId(raw.templateId)) throw new Error('Unknown project template');
const agents = Array.isArray(raw.agents) ? raw.agents.map(normalizeAgent) : [];
if (agents.some((item) => !item)) throw new Error('Invalid project Agent configuration');
const normalizedAgents = agents.filter((item): item is ProjectAgentConfig => Boolean(item));
const templateRoleNames = new Map(
projectTemplates.find((template) => template.id === raw.templateId)?.agents.map((agent) => [agent.id, agent.roleName]) ?? [],
);
for (const item of normalizedAgents) {
if (item.builtIn && templateRoleNames.has(item.id)) item.roleName = templateRoleNames.get(item.id)!;
}
if (new Set(normalizedAgents.map((item) => item.id)).size !== normalizedAgents.length) throw new Error('Duplicate project Agent id');
const createdAt = typeof raw.createdAt === 'string' && raw.createdAt ? raw.createdAt : new Date().toISOString();
const initialized = raw.initialized === true;
if (initialized && validateAgentNames(normalizedAgents).length > 0) throw new Error('Initialized project has invalid Agent names');
return {
schemaVersion: 1,
templateId: raw.templateId,
initialized,
superpowersEnabled: typeof raw.superpowersEnabled === 'boolean'
? raw.superpowersEnabled
: getDefaultSuperpowersEnabled(raw.templateId),
defaultModel: typeof raw.defaultModel === 'string' && raw.defaultModel.trim() ? raw.defaultModel.trim() : null,
agents: normalizedAgents,
knowledgeDirectory: 'knowledge',
createdAt,
updatedAt: typeof raw.updatedAt === 'string' && raw.updatedAt ? raw.updatedAt : createdAt,
};
}
export async function readProjectConfig(projectPath: string): Promise<ProjectConfigReadResult> {
try {
const raw = JSON.parse(await readFile(configPath(projectPath), 'utf8')) as unknown;
return { status: 'valid', config: normalizeProjectConfig(raw) };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { status: 'missing' };
return { status: 'invalid', error: error instanceof Error ? error.message : String(error) };
}
}
function yamlString(value: string): string {
return JSON.stringify(value);
}
export function buildProjectAgentPrompt(config: ProjectConfig, current: ProjectAgentConfig): string {
const peers = config.agents.filter((agent) => agent.id !== current.id);
const versionOwnerId = versionOwnerForTemplate(config.templateId);
const versionAuthority = current.id === versionOwnerId
? [
'## 版本负责人权限',
'- 你是当前项目的版本负责人。评估用户和其他 Agent 在 `TASKS.md` 中留下的升版建议,并在新版工作开始前创建版本范围。',
'- 初始版本是 v0.1.0。修复、小优化或素材替换使用 PATCH新增用户/玩家可感知能力使用 MINOR正式发布或不兼容核心变化使用 MAJOR。',
'- 关闭版本前确认所有任务为 Done、Deferred 或明确 Blocked创建增量 `docs/versions/<version>/VERSION_SUMMARY.md`,只归档本版本变化的文档,把未完成任务带来源迁移到下一版本。',
'- 只有你或用户可以把 `VERSION.md` 状态改为 active、released、archived 或 cancelled。归档后只通过明确勘误修正历史。',
'',
]
: [];
const lines = [
`# ${current.name} · ${current.roleName}`,
'',
`你的名字是「${current.name}」,在与用户对话和介绍自己时使用这个名字。`,
`你在当前项目中的职能是「${current.roleName}」。`,
'',
`你的稳定角色 ID 是 \`${current.id}\`。你只属于当前项目。`,
'',
current.prompt || '按照用户要求完成当前项目内的职责。',
'面向学生、家长或老师输出内容前,使用 `youth-plain-language`,把结论、操作和必要技术词讲成 10 至 16 岁青少年能直接看懂的简体中文。',
...versionAuthority,
'- 这些边界是协作规则,不是文件系统权限。用户明确要求跨界时可以执行,但应说明影响。',
'',
'## 项目伙伴',
...peers.map((peer) => `- ${peer.name}${peer.roleName}${peer.id}`),
'',
'遇到属于其他 Agent 的任务时,明确指出合适的伙伴、需要交接的文件和下一步,不要假装其他 Agent 不存在。',
'',
'- 所有项目 Agent 共享当前项目目录;项目文件是跨会话协作的权威上下文,不依赖自动派发。',
'- 开始前读取与任务有关的上游产物,结束前把实际进展、假设、证据、风险和下一步写入你负责的项目文件。聊天总结不能代替文件产物。',
'- 每次开始工作前读取 `VERSION.md` 和 `TASKS.md`。`VERSION.md` 是当前项目版本的唯一权威索引;`TASKS.md` 是所有 Agent 共同维护的当前版本任务中枢。',
'- 只更新与你工作有关的共享任务状态、Owner、验收、证据、阻塞、版本建议和文档链接专业事实仍写入你的角色产物。',
'- 更新角色 Markdown 文档时同步 `Project Version`、`Document Revision`、`Last Updated By`。项目版本取自 `VERSION.md`,每次实质修改增加文档修订号。',
'- 如果你不是当前 `Version Owner`,只能在 `TASKS.md` 提议 PATCH、MINOR 或 MAJOR 版本及理由,不自行创建或关闭项目版本。用户直接指定版本时遵循用户决定。',
'- 根目录文档只保存当前有效内容;历史版本使用 `docs/versions/<version>/` 的增量快照和 `VERSION_SUMMARY.md`,归档内容原则上只读。',
'- 优先读取项目根目录 `knowledge/` 中与任务相关的资料。',
'- 尊重用户已有文件和未提交改动。',
'',
];
return lines.join('\n');
}
function buildAgentMarkdown(config: ProjectConfig, agent: ProjectAgentConfig): string {
const skills = agent.skillIds.length > 0
? agent.skillIds.map((skill) => ` ${skill}: allow`).join('\n')
: ' "*": deny';
const shellPermission = agent.skillIds.includes('game-assets') ? ' bash: allow\n' : '';
const model = agent.model ?? config.defaultModel;
return `---
description: ${yamlString(agent.name)}
mode: all
${model ? `model: ${yamlString(model)}\n` : ''}permission:
${shellPermission} # Skills that produce local assets need the bundled CLI, never a remote bootstrap.
skill:
${skills}
---
${buildProjectAgentPrompt(config, agent)}`;
}
async function materializeAgents(projectPath: string, config: ProjectConfig): Promise<void> {
const agentDirectory = path.join(projectPath, '.opencode', 'agent');
await mkdir(agentDirectory, { recursive: true });
await Promise.all(config.agents.map(async (agent) => {
await writeFile(path.join(agentDirectory, `${agent.id}.md`), buildAgentMarkdown(config, agent), 'utf8');
}));
}
export async function createInitialProjectConfig(
projectPath: string,
templateId: ProjectTemplateId,
options?: { defaultModel?: string | null },
): Promise<ProjectConfig> {
const config = createProjectConfig(templateId);
if (typeof options?.defaultModel === 'string' && options.defaultModel.trim()) {
config.defaultModel = options.defaultModel.trim();
}
await mkdir(path.join(projectPath, '.niancode'), { recursive: true });
await mkdir(path.join(projectPath, 'knowledge'), { recursive: true });
await writeFile(configPath(projectPath), `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
await writeFile(path.join(projectPath, 'VERSION.md'), initialVersionDocument(templateId, config.createdAt), { encoding: 'utf8', flag: 'wx' });
await writeFile(path.join(projectPath, 'TASKS.md'), initialTaskDocument(templateId), { encoding: 'utf8', flag: 'wx' });
if (templateId === 'game-development') {
await Promise.all(GAME_PROJECT_DIRECTORIES.map((directory) => mkdir(path.join(projectPath, directory), { recursive: true })));
await Promise.all(Object.entries(buildGameProjectDocuments()).map(([fileName, contents]) => (
writeFile(path.join(projectPath, fileName), contents, { encoding: 'utf8', flag: 'wx' })
)));
}
return config;
}
export async function writeProjectConfig(projectPath: string, value: unknown): Promise<ProjectConfig> {
const previous = await readProjectConfig(projectPath);
if (previous.status !== 'valid') throw new Error('Project configuration is missing or invalid');
const config = normalizeProjectConfig({
...(value as object),
schemaVersion: 1,
templateId: previous.config.templateId,
createdAt: previous.config.createdAt,
updatedAt: new Date().toISOString(),
});
if (config.initialized) {
const nameErrors = validateAgentNames(config.agents);
if (nameErrors.length > 0) throw new Error(`Invalid Agent names: ${nameErrors.join(', ')}`);
await materializeAgents(projectPath, config);
}
await writeFile(configPath(projectPath), `${JSON.stringify(config, null, 2)}\n`, 'utf8');
return config;
}
export async function listProjectKnowledge(projectPath: string): Promise<string[]> {
const directory = path.join(projectPath, 'knowledge');
await mkdir(directory, { recursive: true });
return (await readdir(directory, { withFileTypes: true }))
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort((left, right) => left.localeCompare(right));
}