feat: update Makelore modules and conversations
This commit is contained in:
@@ -1,386 +0,0 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { BuiltInRoleSubagentId } from '../../src/lib/role-subagents';
|
||||
|
||||
const COURSE_ROLE_IDS = [
|
||||
'pm',
|
||||
'product',
|
||||
'designer',
|
||||
'dev',
|
||||
'marketing',
|
||||
'deploy',
|
||||
'gameplay',
|
||||
'assets',
|
||||
'game-dev',
|
||||
'game-release',
|
||||
'game-showcase',
|
||||
] as const satisfies readonly BuiltInRoleSubagentId[];
|
||||
const BASE_REQUIRED_SKILL_IDS = ['youth-plain-language'] as const;
|
||||
|
||||
export type CourseRuntimeRoleId = typeof COURSE_ROLE_IDS[number];
|
||||
export type RuntimeSubagentTemplateRoleId = CourseRuntimeRoleId | 'custom';
|
||||
|
||||
export interface RuntimeSubagentCreateInput {
|
||||
projectPath: string;
|
||||
displayName: string;
|
||||
templateRoleId: RuntimeSubagentTemplateRoleId;
|
||||
prompt?: string;
|
||||
requiredSkillIds?: string[];
|
||||
}
|
||||
|
||||
export interface RuntimeSubagentCreateResult {
|
||||
id: string;
|
||||
displayName: string;
|
||||
templateRoleId: RuntimeSubagentTemplateRoleId;
|
||||
filePath: string;
|
||||
skillIds: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RuntimeSubagentUpdateInput {
|
||||
projectPath: string;
|
||||
agentId: string;
|
||||
skillIds: string[];
|
||||
requiredSkillIds?: string[];
|
||||
displayName?: string;
|
||||
templateRoleId?: RuntimeSubagentTemplateRoleId;
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeSubagentUpdateResult {
|
||||
id: string;
|
||||
filePath: string;
|
||||
skillIds: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function isCourseRoleId(value: unknown): value is CourseRuntimeRoleId {
|
||||
return typeof value === 'string' && COURSE_ROLE_IDS.includes(value as CourseRuntimeRoleId);
|
||||
}
|
||||
|
||||
function isTemplateRoleId(value: unknown): value is RuntimeSubagentTemplateRoleId {
|
||||
return value === 'custom' || isCourseRoleId(value);
|
||||
}
|
||||
|
||||
function getResourcesPath(): string | null {
|
||||
const processWithResources = process as NodeJS.Process & { resourcesPath?: string };
|
||||
return typeof processWithResources.resourcesPath === 'string' && processWithResources.resourcesPath
|
||||
? processWithResources.resourcesPath
|
||||
: null;
|
||||
}
|
||||
|
||||
function getTemplateCandidates(templateRoleId: CourseRuntimeRoleId): string[] {
|
||||
const resourcesPath = getResourcesPath();
|
||||
return [
|
||||
...(resourcesPath ? [path.join(resourcesPath, 'course-agents', `${templateRoleId}.md`)] : []),
|
||||
path.resolve(currentDir, '..', '..', '.opencode', 'agent', `${templateRoleId}.md`),
|
||||
path.resolve(process.cwd(), '.opencode', 'agent', `${templateRoleId}.md`),
|
||||
];
|
||||
}
|
||||
|
||||
async function readBuiltInTemplate(templateRoleId: CourseRuntimeRoleId): Promise<string> {
|
||||
const templatePath = getTemplateCandidates(templateRoleId).find((candidate) => existsSync(candidate));
|
||||
if (!templatePath) {
|
||||
throw new Error(`Missing built-in subagent template: ${templateRoleId}`);
|
||||
}
|
||||
return await readFile(templatePath, 'utf8');
|
||||
}
|
||||
|
||||
function yamlString(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function buildCustomAgentMarkdown(displayName: string, prompt: string): string {
|
||||
return `---
|
||||
description: ${yamlString(displayName)}
|
||||
mode: all
|
||||
color: "#64D2C8"
|
||||
permission:
|
||||
"*": deny
|
||||
read: allow
|
||||
glob: allow
|
||||
grep: allow
|
||||
list: allow
|
||||
question: allow
|
||||
todowrite: allow
|
||||
skill:
|
||||
"*": deny
|
||||
---
|
||||
|
||||
${prompt.trim()}
|
||||
`;
|
||||
}
|
||||
|
||||
function normalizeDisplayName(value: unknown): string {
|
||||
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||
if (!normalized) throw new Error('Missing subagent display name');
|
||||
if (normalized.length > 80) throw new Error('Subagent display name is too long');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizePrompt(value: unknown): string {
|
||||
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||
if (!normalized) throw new Error('Missing custom subagent prompt');
|
||||
if (normalized.length > 20000) throw new Error('Custom subagent prompt is too long');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeAgentId(value: unknown): string {
|
||||
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||
if (!normalized) throw new Error('Missing subagent id');
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(normalized)) throw new Error('Invalid subagent id');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeSkillIds(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) throw new Error('Missing skill ids');
|
||||
const result: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of value) {
|
||||
const skillId = typeof item === 'string' ? item.trim() : '';
|
||||
if (!skillId) continue;
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(skillId)) throw new Error(`Invalid skill id: ${skillId}`);
|
||||
if (!seen.has(skillId)) {
|
||||
seen.add(skillId);
|
||||
result.push(skillId);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeOptionalSkillIds(value: unknown): string[] {
|
||||
return value === undefined ? [] : normalizeSkillIds(value);
|
||||
}
|
||||
|
||||
function mergeSkillIds(...skillLists: Array<readonly string[]>): string[] {
|
||||
const result: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const skillList of skillLists) {
|
||||
for (const skillId of skillList) {
|
||||
if (!seen.has(skillId)) {
|
||||
seen.add(skillId);
|
||||
result.push(skillId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateInput(input: RuntimeSubagentCreateInput): Omit<RuntimeSubagentCreateInput, 'prompt'> & { prompt?: string } {
|
||||
const displayName = normalizeDisplayName(input.displayName);
|
||||
if (!isTemplateRoleId(input.templateRoleId)) throw new Error('Invalid template role id');
|
||||
return {
|
||||
projectPath: input.projectPath,
|
||||
displayName,
|
||||
templateRoleId: input.templateRoleId,
|
||||
...(input.templateRoleId === 'custom' ? { prompt: normalizePrompt(input.prompt) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function validateRestoreInput(input: RuntimeSubagentUpdateInput): RuntimeSubagentCreateInput {
|
||||
const displayName = normalizeDisplayName(input.displayName);
|
||||
if (!isTemplateRoleId(input.templateRoleId)) throw new Error('Invalid template role id');
|
||||
return {
|
||||
projectPath: input.projectPath,
|
||||
displayName,
|
||||
templateRoleId: input.templateRoleId,
|
||||
...(input.templateRoleId === 'custom' ? { prompt: normalizePrompt(input.prompt) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createAgentId(templateRoleId: RuntimeSubagentTemplateRoleId): string {
|
||||
const timestamp = new Date().toISOString()
|
||||
.replace(/\D/g, '')
|
||||
.slice(0, 14);
|
||||
return `nitu-${templateRoleId}-${timestamp}-${randomBytes(3).toString('hex')}`;
|
||||
}
|
||||
|
||||
async function writeUniqueAgentFile(agentDir: string, id: string, markdown: string): Promise<{ id: string; filePath: string }> {
|
||||
let candidateId = id;
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
const filePath = path.join(agentDir, `${candidateId}.md`);
|
||||
if (!existsSync(filePath)) {
|
||||
await writeFile(filePath, markdown, { encoding: 'utf8', flag: 'wx' });
|
||||
return { id: candidateId, filePath };
|
||||
}
|
||||
candidateId = `${id}-${randomBytes(2).toString('hex')}`;
|
||||
}
|
||||
throw new Error('Failed to create a unique subagent id');
|
||||
}
|
||||
|
||||
async function buildRuntimeSubagentMarkdown(input: RuntimeSubagentCreateInput): Promise<string> {
|
||||
return input.templateRoleId === 'custom'
|
||||
? buildCustomAgentMarkdown(input.displayName, normalizePrompt(input.prompt))
|
||||
: await readBuiltInTemplate(input.templateRoleId);
|
||||
}
|
||||
|
||||
function splitFrontmatter(markdown: string): { frontmatter: string; body: string } {
|
||||
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n)?([\s\S]*)$/);
|
||||
if (!match) throw new Error('Subagent file is missing YAML frontmatter');
|
||||
return {
|
||||
frontmatter: match[1] ?? '',
|
||||
body: match[2] ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function countIndent(line: string): number {
|
||||
const match = line.match(/^ */);
|
||||
return match?.[0].length ?? 0;
|
||||
}
|
||||
|
||||
function yamlKey(value: string): string {
|
||||
return /^[a-z0-9][a-z0-9-]*$/.test(value) ? value : yamlString(value);
|
||||
}
|
||||
|
||||
function buildSkillPermissionLines(skillIds: string[]): string[] {
|
||||
return [
|
||||
' skill:',
|
||||
...(skillIds.length > 0
|
||||
? skillIds.map((skillId) => ` ${yamlKey(skillId)}: allow`)
|
||||
: [' "*": deny']),
|
||||
];
|
||||
}
|
||||
|
||||
function findPermissionBlock(lines: string[]): { start: number; end: number } | null {
|
||||
const start = lines.findIndex((line) => /^permission:\s*$/.test(line));
|
||||
if (start < 0) return null;
|
||||
let end = lines.length;
|
||||
for (let index = start + 1; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
if (line.trim() && countIndent(line) === 0) {
|
||||
end = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function findPermissionSkillBlock(lines: string[], permissionStart: number, permissionEnd: number): { start: number; end: number } | null {
|
||||
for (let index = permissionStart + 1; index < permissionEnd; index += 1) {
|
||||
if (/^ {2}skill:\s*$/.test(lines[index])) {
|
||||
let end = permissionEnd;
|
||||
for (let cursor = index + 1; cursor < permissionEnd; cursor += 1) {
|
||||
const line = lines[cursor];
|
||||
if (line.trim() && countIndent(line) <= 2) {
|
||||
end = cursor;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { start: index, end };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function updateAgentSkillPermissions(markdown: string, skillIds: string[]): string {
|
||||
const { frontmatter, body } = splitFrontmatter(markdown);
|
||||
const lines = frontmatter.split(/\r?\n/);
|
||||
const skillLines = buildSkillPermissionLines(skillIds);
|
||||
const permissionBlock = findPermissionBlock(lines);
|
||||
|
||||
if (!permissionBlock) {
|
||||
lines.push('permission:', ...skillLines);
|
||||
} else {
|
||||
const skillBlock = findPermissionSkillBlock(lines, permissionBlock.start, permissionBlock.end);
|
||||
if (skillBlock) {
|
||||
lines.splice(skillBlock.start, skillBlock.end - skillBlock.start, ...skillLines);
|
||||
} else {
|
||||
lines.splice(permissionBlock.end, 0, ...skillLines);
|
||||
}
|
||||
}
|
||||
|
||||
return `---\n${lines.join('\n')}\n---\n\n${body.replace(/^\r?\n/, '')}`;
|
||||
}
|
||||
|
||||
function readBoundSkillIds(markdown: string): string[] {
|
||||
const { frontmatter } = splitFrontmatter(markdown);
|
||||
const lines = frontmatter.split(/\r?\n/);
|
||||
const permissionBlock = findPermissionBlock(lines);
|
||||
if (!permissionBlock) return [];
|
||||
const skillBlock = findPermissionSkillBlock(lines, permissionBlock.start, permissionBlock.end);
|
||||
if (!skillBlock) return [];
|
||||
|
||||
const result: string[] = [];
|
||||
for (let index = skillBlock.start + 1; index < skillBlock.end; index += 1) {
|
||||
const match = lines[index].match(/^ {4}(?:"([^"]+)"|([A-Za-z0-9][A-Za-z0-9._-]*)):\s*(allow|deny)\s*$/);
|
||||
if (!match) continue;
|
||||
const key = match[1] ?? match[2] ?? '';
|
||||
const action = match[3];
|
||||
if (key !== '*' && action === 'allow') result.push(key);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function canRefreshFromMetadata(input: RuntimeSubagentUpdateInput): boolean {
|
||||
const hasDisplayName = typeof input.displayName === 'string' && input.displayName.trim().length > 0;
|
||||
if (!hasDisplayName || !isTemplateRoleId(input.templateRoleId)) return false;
|
||||
if (input.templateRoleId === 'custom') {
|
||||
return typeof input.prompt === 'string' && input.prompt.trim().length > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function createRuntimeSubagent(input: RuntimeSubagentCreateInput): Promise<RuntimeSubagentCreateResult> {
|
||||
const normalized = validateInput(input);
|
||||
const requiredSkillIds = normalizeOptionalSkillIds(input.requiredSkillIds);
|
||||
const agentDir = path.join(path.resolve(normalized.projectPath), '.opencode', 'agent');
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
|
||||
const baseMarkdown = await buildRuntimeSubagentMarkdown(normalized);
|
||||
const skillIds = mergeSkillIds(readBoundSkillIds(baseMarkdown), requiredSkillIds, BASE_REQUIRED_SKILL_IDS);
|
||||
const markdown = updateAgentSkillPermissions(baseMarkdown, skillIds);
|
||||
const createdAt = new Date().toISOString();
|
||||
const written = await writeUniqueAgentFile(agentDir, createAgentId(normalized.templateRoleId), markdown);
|
||||
|
||||
return {
|
||||
id: written.id,
|
||||
displayName: normalized.displayName,
|
||||
templateRoleId: normalized.templateRoleId,
|
||||
filePath: written.filePath,
|
||||
skillIds,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateRuntimeSubagent(input: RuntimeSubagentUpdateInput): Promise<RuntimeSubagentUpdateResult> {
|
||||
const projectPath = path.resolve(input.projectPath);
|
||||
const agentId = normalizeAgentId(input.agentId);
|
||||
const skillIds = mergeSkillIds(
|
||||
normalizeSkillIds(input.skillIds),
|
||||
normalizeOptionalSkillIds(input.requiredSkillIds),
|
||||
BASE_REQUIRED_SKILL_IDS,
|
||||
);
|
||||
const agentDir = path.join(projectPath, '.opencode', 'agent');
|
||||
const filePath = path.resolve(agentDir, `${agentId}.md`);
|
||||
const relative = path.relative(agentDir, filePath);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('Invalid subagent id');
|
||||
|
||||
let currentMarkdown: string;
|
||||
try {
|
||||
currentMarkdown = await readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
const restoreInput = validateRestoreInput(input);
|
||||
currentMarkdown = await buildRuntimeSubagentMarkdown(restoreInput);
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
}
|
||||
const baseMarkdown = canRefreshFromMetadata(input)
|
||||
? await buildRuntimeSubagentMarkdown(validateRestoreInput(input))
|
||||
: currentMarkdown;
|
||||
const nextMarkdown = updateAgentSkillPermissions(baseMarkdown, skillIds);
|
||||
await writeFile(filePath, nextMarkdown, 'utf8');
|
||||
|
||||
return {
|
||||
id: agentId,
|
||||
filePath,
|
||||
skillIds,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
type SpawnOptionsWithoutStdio,
|
||||
} from 'node:child_process';
|
||||
import {
|
||||
ensureBundledCourseAgents,
|
||||
ensureBundledCourseSkills,
|
||||
ensureBundledSuperpowersPlugin,
|
||||
getManagedOpencodeConfigDir,
|
||||
@@ -163,7 +162,6 @@ export interface OpencodeManagerOptions {
|
||||
binPath: string;
|
||||
userDataDir?: string;
|
||||
bundledSuperpowersDir?: string;
|
||||
bundledCourseAgentsDir?: string;
|
||||
bundledCourseSkillsDir?: string;
|
||||
pythonRuntime?: PythonRuntime;
|
||||
spawn?: SpawnFn;
|
||||
@@ -582,10 +580,6 @@ export class OpencodeManager extends EventEmitter {
|
||||
managedConfigDir,
|
||||
sourceDir: this.options.bundledCourseSkillsDir,
|
||||
});
|
||||
ensureBundledCourseAgents({
|
||||
managedConfigDir,
|
||||
sourceDir: this.options.bundledCourseAgentsDir,
|
||||
});
|
||||
const environment = {
|
||||
...runtimeEnv,
|
||||
OPENCODE_CONFIG_DIR: managedConfigDir,
|
||||
|
||||
@@ -2,133 +2,24 @@ 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,
|
||||
validateAgentConfigs,
|
||||
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.0;3D 使用 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))];
|
||||
return [...new Set(value
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean))];
|
||||
}
|
||||
|
||||
function normalizeAgent(value: unknown): ProjectAgentConfig | null {
|
||||
@@ -137,17 +28,23 @@ function normalizeAgent(value: unknown): ProjectAgentConfig | null {
|
||||
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;
|
||||
const rawResponsibility = raw.responsibility;
|
||||
const responsibility = rawResponsibility && typeof rawResponsibility === 'object'
|
||||
? rawResponsibility as Partial<ProjectAgentConfig['responsibility']>
|
||||
: {};
|
||||
return {
|
||||
id,
|
||||
avatarId: typeof raw.avatarId === 'string' && /^avatar-(0[1-9]|1[0-6])$/.test(raw.avatarId) ? raw.avatarId : 'avatar-01',
|
||||
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,
|
||||
// Preserve this legacy marker when reading an existing project. New
|
||||
// projects never populate it because they start with no Agents.
|
||||
builtIn: raw.builtIn === true,
|
||||
enabled: true,
|
||||
enabled: raw.enabled !== false,
|
||||
model: typeof raw.model === 'string' && raw.model.trim() ? raw.model.trim() : null,
|
||||
skillIds: [...new Set([YOUTH_PLAIN_LANGUAGE_SKILL_ID, ...normalizeStringList(raw.skillIds)])],
|
||||
skillIds: normalizeStringList(raw.skillIds),
|
||||
responsibility: {
|
||||
mission: typeof responsibility.mission === 'string' ? responsibility.mission.trim() : '',
|
||||
owns: normalizeStringList(responsibility.owns),
|
||||
@@ -155,47 +52,45 @@ function normalizeAgent(value: unknown): ProjectAgentConfig | null {
|
||||
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),
|
||||
},
|
||||
),
|
||||
prompt: typeof raw.prompt === 'string' ? raw.prompt.trim() : '',
|
||||
archivedAt: typeof raw.archivedAt === 'string' && raw.archivedAt.trim() ? raw.archivedAt.trim() : null,
|
||||
pinned: raw.pinned === true,
|
||||
createdAt: typeof raw.createdAt === 'string' && raw.createdAt.trim() ? raw.createdAt.trim() : undefined,
|
||||
updatedAt: typeof raw.updatedAt === 'string' && raw.updatedAt.trim() ? raw.updatedAt.trim() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export type ProjectConfigReadResult =
|
||||
| { status: 'valid'; config: ProjectConfig }
|
||||
| { status: 'missing' }
|
||||
| { status: 'invalid'; error: string };
|
||||
|
||||
export function normalizeProjectConfig(value: unknown): ProjectConfig {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Project config must be an object');
|
||||
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');
|
||||
}
|
||||
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 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');
|
||||
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,
|
||||
superpowersEnabled: raw.superpowersEnabled === true,
|
||||
defaultModel: typeof raw.defaultModel === 'string' && raw.defaultModel.trim()
|
||||
? raw.defaultModel.trim()
|
||||
: null,
|
||||
agents: normalizedAgents,
|
||||
knowledgeDirectory: 'knowledge',
|
||||
createdAt,
|
||||
@@ -219,47 +114,40 @@ function yamlString(value: string): string {
|
||||
|
||||
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
|
||||
const responsibility = current.responsibility.mission.trim()
|
||||
? [
|
||||
'## 版本负责人权限',
|
||||
'- 你是当前项目的版本负责人。评估用户和其他 Agent 在 `TASKS.md` 中留下的升版建议,并在新版工作开始前创建版本范围。',
|
||||
'- 初始版本是 v0.1.0。修复、小优化或素材替换使用 PATCH;新增用户/玩家可感知能力使用 MINOR;正式发布或不兼容核心变化使用 MAJOR。',
|
||||
'- 关闭版本前确认所有任务为 Done、Deferred 或明确 Blocked;创建增量 `docs/versions/<version>/VERSION_SUMMARY.md`,只归档本版本变化的文档,把未完成任务带来源迁移到下一版本。',
|
||||
'- 只有你或用户可以把 `VERSION.md` 状态改为 active、released、archived 或 cancelled。归档后只通过明确勘误修正历史。',
|
||||
'## 你的职责',
|
||||
current.responsibility.mission,
|
||||
'',
|
||||
'## 负责内容',
|
||||
...current.responsibility.owns.map((item) => `- ${item}`),
|
||||
'',
|
||||
'## 工作边界',
|
||||
...current.responsibility.boundaries.map((item) => `- ${item}`),
|
||||
'',
|
||||
'## 工作原则',
|
||||
...current.responsibility.principles.map((item) => `- ${item}`),
|
||||
'',
|
||||
]
|
||||
: [];
|
||||
const lines = [
|
||||
return [
|
||||
`# ${current.name} · ${current.roleName}`,
|
||||
'',
|
||||
`你的名字是「${current.name}」,在与用户对话和介绍自己时使用这个名字。`,
|
||||
`你在当前项目中的职能是「${current.roleName}」。`,
|
||||
'',
|
||||
`你的稳定角色 ID 是 \`${current.id}\`。你只属于当前项目。`,
|
||||
'',
|
||||
current.prompt || '按照用户要求完成当前项目内的职责。',
|
||||
'面向学生、家长或老师输出内容前,使用 `youth-plain-language`,把结论、操作和必要技术词讲成 10 至 16 岁青少年能直接看懂的简体中文。',
|
||||
...versionAuthority,
|
||||
'- 这些边界是协作规则,不是文件系统权限。用户明确要求跨界时可以执行,但应说明影响。',
|
||||
current.prompt.trim(),
|
||||
...responsibility,
|
||||
'请尊重用户已有文件和未提交改动;遇到不确定的高影响决策时先说明影响。',
|
||||
'',
|
||||
'## 项目伙伴',
|
||||
...peers.map((peer) => `- ${peer.name}(${peer.roleName},${peer.id})`),
|
||||
...(peers.length > 0
|
||||
? 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');
|
||||
'项目目录是当前项目的长期上下文。开始工作前读取与任务相关的文件,结束时把实际进展、假设、证据、风险和下一步写入项目文件。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildAgentMarkdown(config: ProjectConfig, agent: ProjectAgentConfig): string {
|
||||
@@ -268,6 +156,7 @@ function buildAgentMarkdown(config: ProjectConfig, agent: ProjectAgentConfig): s
|
||||
: ' "*": deny';
|
||||
const shellPermission = agent.skillIds.includes('game-assets') ? ' bash: allow\n' : '';
|
||||
const model = agent.model ?? config.defaultModel;
|
||||
const prompt = agent.prompt.trim() || buildProjectAgentPrompt(config, agent);
|
||||
return `---
|
||||
description: ${yamlString(agent.name)}
|
||||
mode: all
|
||||
@@ -277,7 +166,7 @@ ${shellPermission} # Skills that produce local assets need the bundled CLI, nev
|
||||
${skills}
|
||||
---
|
||||
|
||||
${buildProjectAgentPrompt(config, agent)}`;
|
||||
${prompt}`;
|
||||
}
|
||||
|
||||
async function materializeAgents(projectPath: string, config: ProjectConfig): Promise<void> {
|
||||
@@ -288,26 +177,71 @@ async function materializeAgents(projectPath: string, config: ProjectConfig): Pr
|
||||
}));
|
||||
}
|
||||
|
||||
function initialVersionDocument(now: string): string {
|
||||
return `# Project Version
|
||||
|
||||
Current: v0.1.0
|
||||
Status: active
|
||||
Version Owner: user
|
||||
Started At: ${now}
|
||||
Task Plan: TASKS.md
|
||||
Summary: docs/versions/v0.1.0/VERSION_SUMMARY.md
|
||||
Previous: none
|
||||
`;
|
||||
}
|
||||
|
||||
function initialTaskDocument(): string {
|
||||
return `# TASKS
|
||||
|
||||
Project Version: v0.1.0
|
||||
Document Revision: 1
|
||||
Last Updated By: user
|
||||
|
||||
> 这里记录当前项目的任务状态、验收、证据、阻塞和下一步;专业事实写入对应项目文件。
|
||||
|
||||
## Version Goal
|
||||
|
||||
- 定义 v0.1.0 的最小可验证目标。
|
||||
|
||||
## Now
|
||||
|
||||
- [ ] TASK-001 明确当前版本目标
|
||||
- Owner: user
|
||||
- Status: todo
|
||||
- Acceptance: 当前版本范围、验收和受影响文档已写清
|
||||
- Evidence: pending
|
||||
|
||||
## Next
|
||||
|
||||
- None
|
||||
|
||||
## Blocked
|
||||
|
||||
- None
|
||||
|
||||
## Version Proposals
|
||||
|
||||
- None
|
||||
|
||||
## Done
|
||||
|
||||
- None
|
||||
`;
|
||||
}
|
||||
|
||||
export async function createInitialProjectConfig(
|
||||
projectPath: string,
|
||||
templateId: ProjectTemplateId,
|
||||
options?: { defaultModel?: string | null },
|
||||
): Promise<ProjectConfig> {
|
||||
const config = createProjectConfig(templateId);
|
||||
const config = createProjectConfig();
|
||||
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' })
|
||||
)));
|
||||
}
|
||||
await writeFile(path.join(projectPath, 'VERSION.md'), initialVersionDocument(config.createdAt), { encoding: 'utf8', flag: 'wx' });
|
||||
await writeFile(path.join(projectPath, 'TASKS.md'), initialTaskDocument(), { encoding: 'utf8', flag: 'wx' });
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -317,13 +251,14 @@ export async function writeProjectConfig(projectPath: string, value: unknown): P
|
||||
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(', ')}`);
|
||||
const validationErrors = validateAgentConfigs(config.agents);
|
||||
if (validationErrors.length > 0) {
|
||||
throw new Error(`Invalid project contact configuration: ${validationErrors.join(', ')}`);
|
||||
}
|
||||
await materializeAgents(projectPath, config);
|
||||
}
|
||||
await writeFile(configPath(projectPath), `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
||||
|
||||
33
electron/opencode/project-conversations.ts
Normal file
33
electron/opencode/project-conversations.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import path from 'node:path';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
createProjectConversationState,
|
||||
normalizeProjectConversationState,
|
||||
type ProjectConversationState,
|
||||
} from '../../shared/project-conversations';
|
||||
|
||||
export const PROJECT_CONVERSATIONS_PATH = '.niancode/conversations.json';
|
||||
|
||||
function statePath(projectPath: string): string {
|
||||
return path.join(projectPath, PROJECT_CONVERSATIONS_PATH);
|
||||
}
|
||||
|
||||
export async function readProjectConversationState(projectPath: string): Promise<ProjectConversationState> {
|
||||
try {
|
||||
const raw = JSON.parse(await readFile(statePath(projectPath), 'utf8')) as unknown;
|
||||
return normalizeProjectConversationState(raw);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return createProjectConversationState();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeProjectConversationState(
|
||||
projectPath: string,
|
||||
value: unknown,
|
||||
): Promise<ProjectConversationState> {
|
||||
const state = normalizeProjectConversationState(value);
|
||||
await mkdir(path.dirname(statePath(projectPath)), { recursive: true });
|
||||
await writeFile(statePath(projectPath), `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
||||
return state;
|
||||
}
|
||||
@@ -11,9 +11,8 @@ import {
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, sep } from 'node:path';
|
||||
import type { ProjectConfig, ProjectTemplateId } from '../../shared/project-config';
|
||||
import type { ProjectConfig } from '../../shared/project-config';
|
||||
import { createInitialProjectConfig, readProjectConfig } from './project-config';
|
||||
import { initializeWorksPackageTemplate } from './project-package-template';
|
||||
|
||||
type StagedProjectEntries = {
|
||||
directories: string[];
|
||||
@@ -27,7 +26,6 @@ export type ProjectDirectoryInitializationResult = {
|
||||
|
||||
export type ProjectDirectoryInitializationInput = {
|
||||
projectPath: string;
|
||||
templateId: ProjectTemplateId;
|
||||
defaultModel?: string | null;
|
||||
allowExistingDirectory: boolean;
|
||||
};
|
||||
@@ -226,18 +224,9 @@ export async function initializeProjectDirectory(
|
||||
try {
|
||||
stagingPath = await mkdtemp(join(tmpdir(), 'niancode-project-init-'));
|
||||
|
||||
try {
|
||||
await initializeWorksPackageTemplate(stagingPath, input.templateId);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Project package template initialization failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
let config: ProjectConfig;
|
||||
try {
|
||||
config = await createInitialProjectConfig(stagingPath, input.templateId, {
|
||||
config = await createInitialProjectConfig(stagingPath, {
|
||||
defaultModel: input.defaultModel,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import type { ProjectTemplateId } from '../../shared/project-config';
|
||||
import { getResourcesDir } from '../utils/paths';
|
||||
|
||||
const TEMPLATE_RELATIVE_DIRECTORY = join('project-templates', 'works-compose');
|
||||
const SHARED_FILES = [
|
||||
'.dockerignore',
|
||||
'Dockerfile',
|
||||
'docker-compose.yml',
|
||||
'nginx.conf',
|
||||
'niancode.yml',
|
||||
'deploy/works-deploy-check.schema.json',
|
||||
'deploy/部署报告模板.md',
|
||||
] as const;
|
||||
|
||||
const EXAMPLE_BY_TEMPLATE: Record<ProjectTemplateId, string> = {
|
||||
'standard-development': 'deploy/examples/works-deploy-check.web.json',
|
||||
'game-development': 'deploy/examples/works-deploy-check.game.json',
|
||||
};
|
||||
|
||||
export type WorksPackageTemplateOptions = {
|
||||
resourcesDirectory?: string;
|
||||
};
|
||||
|
||||
export function resolveWorksPackageTemplateDirectory(resourcesDirectory = getResourcesDir()): string {
|
||||
return join(resourcesDirectory, TEMPLATE_RELATIVE_DIRECTORY);
|
||||
}
|
||||
|
||||
export async function initializeWorksPackageTemplate(
|
||||
projectPath: string,
|
||||
templateId: ProjectTemplateId,
|
||||
options: WorksPackageTemplateOptions = {},
|
||||
): Promise<{ files: string[] }> {
|
||||
const templateDirectory = resolveWorksPackageTemplateDirectory(options.resourcesDirectory);
|
||||
const files = [...SHARED_FILES, EXAMPLE_BY_TEMPLATE[templateId]];
|
||||
|
||||
for (const relativePath of files) {
|
||||
const sourcePath = join(templateDirectory, relativePath);
|
||||
try {
|
||||
const contents = await readFile(sourcePath);
|
||||
if (relativePath.endsWith('.json')) JSON.parse(contents.toString('utf8'));
|
||||
const destinationPath = join(projectPath, relativePath);
|
||||
await mkdir(dirname(destinationPath), { recursive: true });
|
||||
await writeFile(destinationPath, contents, { flag: 'wx' });
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Works package template initialization failed for ${relativePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { files };
|
||||
}
|
||||
@@ -1,556 +0,0 @@
|
||||
import { mkdir, readdir, stat, writeFile } from 'node:fs/promises';
|
||||
import { basename, dirname, join, relative } from 'node:path';
|
||||
import { buildInitialProductOverviewDocument, PRODUCT_OVERVIEW_FILENAME } from '../../shared/project-config';
|
||||
import type { ProjectTemplateSnapshot } from '../../shared/project-template';
|
||||
|
||||
export type ProjectScaffoldResult =
|
||||
| { status: 'disabled' }
|
||||
| { status: 'generated'; files: string[] }
|
||||
| { status: 'skipped_non_empty'; entries: string[] }
|
||||
| { status: 'skipped_existing_files'; files: string[] };
|
||||
|
||||
const IGNORABLE_ROOT_ENTRIES = new Set(['.niancode', '.git', '.DS_Store', 'Thumbs.db']);
|
||||
|
||||
const PHASER_SCAFFOLD_FILES = [
|
||||
'ASSET_PLAN.md',
|
||||
'GDD.md',
|
||||
'README.md',
|
||||
'RELEASE_CHECKLIST.md',
|
||||
PRODUCT_OVERVIEW_FILENAME,
|
||||
'TASKS.md',
|
||||
'index.html',
|
||||
'package.json',
|
||||
'src/main.ts',
|
||||
'src/styles.css',
|
||||
'tsconfig.json',
|
||||
'vite.config.ts',
|
||||
] as const;
|
||||
|
||||
const PHASER_SCAFFOLD_ROOTS = new Set(PHASER_SCAFFOLD_FILES.map((filePath) => filePath.split('/')[0] ?? filePath));
|
||||
|
||||
const PHASER_SCAFFOLD_ALLOWED_FILE_PATHS = new Set<string>(PHASER_SCAFFOLD_FILES);
|
||||
|
||||
const PHASER_SCAFFOLD_ALLOWED_DIRECTORY_PATHS = new Set<string>(
|
||||
PHASER_SCAFFOLD_FILES.flatMap((filePath) => {
|
||||
const parts = filePath.split('/');
|
||||
return parts.slice(0, -1).map((_, index) => parts.slice(0, index + 1).join('/'));
|
||||
}),
|
||||
);
|
||||
|
||||
function toPackageName(projectPath: string): string {
|
||||
const fallback = 'phaser-mini-game-course';
|
||||
const normalized = basename(projectPath)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
function buildPhaserScaffoldFiles(projectPath: string): Array<[string, string]> {
|
||||
const packageName = toPackageName(projectPath);
|
||||
return [
|
||||
['ASSET_PLAN.md', `# 素材计划
|
||||
|
||||
> 这是所有 Agent 共享的素材上下文。素材伙伴持续记录候选、选择状态、来源、授权、项目内路径、缺口、假设和接入建议;聊天总结不能代替本文件。
|
||||
|
||||
## 用户决策
|
||||
|
||||
- 等待用户在审核台逐项决定候选素材。
|
||||
|
||||
## 机器可核验记录
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"selectionStatus": "draft",
|
||||
"selectionDecision": {
|
||||
"mode": "pending",
|
||||
"userQuote": ""
|
||||
},
|
||||
"confirmedAssetIds": [],
|
||||
"unresolvedRequiredAssetIds": ["TODO"],
|
||||
"assets": [
|
||||
{
|
||||
"id": "TODO",
|
||||
"category": "visual",
|
||||
"requiredForCurrentTarget": true,
|
||||
"status": "candidate",
|
||||
"purpose": "TODO",
|
||||
"source": "TODO",
|
||||
"license": "TODO",
|
||||
"localPath": "assets/TODO.png",
|
||||
"previewPath": "assets/TODO.png",
|
||||
"coverPath": "",
|
||||
"manifestPaths": [],
|
||||
"technical": "TODO",
|
||||
"styleRationale": "TODO",
|
||||
"fallback": "TODO"
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 协作规则
|
||||
|
||||
- ASSET_PLAN.md 必须同时保留顶层为 {"assets":[...]} 的 fenced JSON 资产清单,每项使用稳定 id;Markdown 表格只能作为展示说明,不能替代机器记录。
|
||||
- 需要用户选择时,诚实区分候选与已选结果,并保留用户决定;不要把沉默或自己的判断写成用户确认。审核台先收集全部卡片决定,用户全部选完后通过一次确认提交一条汇总消息;不要把决定拆成逐卡聊天。审核台的用户 approve 才是纳入开发的依据;discard/replace 的素材不得再次提交。
|
||||
- 审核状态保存在项目内 \`.niancode/asset-review.json\`。Agent 每次输出审核标记都重新填充候选列表,不自动追加旧批次;用户退出后,未处理素材也不会自动带入下一次。
|
||||
- 未决素材不阻塞可逆的占位工作,但正式开发只能接入审核状态中已批准的素材。下载、接入与运行验证由实际执行的 Agent 记录,不要预先声称完成。
|
||||
- 为了让普通用户在桌面端直接浏览,图片/音频填写项目相对 \`localPath\`;需要单独预览图时填写 \`previewPath\`,动画或素材包填写 \`coverPath\` 和最多 100 条 \`manifestPaths\`。不要填写项目外绝对路径。
|
||||
- 输出审核标记前,逐项确认上述项目内路径真实存在;没有可预览证据的候选先下载/整理预览或继续搜索,不要提交空预览批次。
|
||||
- 候选准备好后必须在同一条回复末尾输出新的审核标记;禁止只问用户选择哪个方案或让用户回复文件名来代替审核台。
|
||||
`],
|
||||
['GDD.md', `# 游戏设计(持续维护)
|
||||
|
||||
> 这里记录当前要做的真实游戏,而不是 Phaser 起步示例。先写清楚下一次可试玩验证需要的内容;每次得到试玩证据后更新它。
|
||||
|
||||
## 状态
|
||||
|
||||
- 阶段:Concept / Prototype / Build / Ship
|
||||
- 当前可玩版本:尚未开始
|
||||
- 最大当前风险:玩家是否能理解核心目标和操作?
|
||||
- 当前可试玩目标:先确认游戏想法。
|
||||
|
||||
## 1. 游戏承诺
|
||||
|
||||
- 一句话游戏:
|
||||
- 玩家幻想与感受:
|
||||
- 目标玩家与无障碍需要:
|
||||
- 浏览器与输入方式:
|
||||
- 一局时长与自然结束点:
|
||||
|
||||
## 2. 支柱与边界
|
||||
|
||||
| 支柱 | 给玩家的可观察承诺 | 发生冲突时优先什么 |
|
||||
| --- | --- | --- |
|
||||
| TODO | TODO | TODO |
|
||||
|
||||
### 本次第一版不做什么
|
||||
|
||||
- TODO
|
||||
|
||||
## 3. 体验链与核心循环
|
||||
|
||||
- 玩家动词:
|
||||
- 核心循环:开始 → 行动 → 反馈 → 结果 → 重开或继续
|
||||
- 成功条件:
|
||||
- 失败条件:
|
||||
- 重开时重置什么、保留什么:
|
||||
|
||||
## 4. 玩家模型与反馈
|
||||
|
||||
- 控制方式与即时反馈:
|
||||
- 玩家必须理解的规则:
|
||||
- 画面、声音、文字和无障碍提示:
|
||||
|
||||
## 5. 当前系统
|
||||
|
||||
### SYS-001 — TODO
|
||||
|
||||
- 玩家目的和选择:
|
||||
- 输入与前置条件:
|
||||
- 状态、规则和结果:
|
||||
- 反馈、边界情况和可调参数:
|
||||
- 可观察验收:
|
||||
|
||||
## 6. 第一版边界
|
||||
|
||||
- 必须有:一个完整的小循环、明确结果、可重开。
|
||||
- 这次不做:TODO
|
||||
|
||||
## 7. 风险与当前实验
|
||||
|
||||
- 假设:
|
||||
- 要验证什么:
|
||||
- Keep / Change / Inconclusive:尚未验证
|
||||
|
||||
## 8. 证据与决定
|
||||
|
||||
- 最近一次真实浏览器试玩:尚未验证
|
||||
- 已确认决定:`],
|
||||
['TASKS.md', `# TASKS
|
||||
|
||||
Project Version: v0.1.0
|
||||
Document Revision: 1
|
||||
Last Updated By: game-design
|
||||
|
||||
> 所有 Agent 共同读取和维护本文件。专业事实留在角色文档;这里保存任务状态、Owner、验收、证据摘要、阻塞、版本建议和链接。
|
||||
|
||||
## Version Goal
|
||||
|
||||
- 先确认游戏想法和下一次可试玩目标。
|
||||
|
||||
## Now
|
||||
|
||||
- [ ] TASK-001 明确当前版本目标
|
||||
- Owner: game-design
|
||||
- Status: todo
|
||||
- Acceptance: v0.1.0 的玩家可感知目标、范围和验证方式已写清
|
||||
- Evidence: pending
|
||||
|
||||
## Next
|
||||
|
||||
- TODO
|
||||
|
||||
## Blocked
|
||||
|
||||
- 无
|
||||
|
||||
## Version Proposals
|
||||
|
||||
- 无
|
||||
|
||||
## Last verified
|
||||
|
||||
- 尚未验证:还没有生产构建或真实浏览器试玩证据。
|
||||
|
||||
## Done
|
||||
|
||||
- 无`],
|
||||
['README.md', `# ${packageName}
|
||||
|
||||
Starter scaffold for the Phaser mini-game course.
|
||||
|
||||
## Commands
|
||||
|
||||
- \`pnpm install\`
|
||||
- \`pnpm dev\`
|
||||
- \`pnpm build\`
|
||||
- \`pnpm preview\`
|
||||
|
||||
## Project Files
|
||||
|
||||
- \`GDD.md\` and \`TASKS.md\` are the canonical game memory.
|
||||
- \`ASSET_PLAN.md\` tracks what to source or draw.
|
||||
- Keep one player-visible result in \`TASKS.md\`; reconcile it with the design after every verified increment.
|
||||
- \`RELEASE_CHECKLIST.md\` tracks testing and ship readiness.
|
||||
- \`${PRODUCT_OVERVIEW_FILENAME}\` is the canonical product operations introduction: product facts, user value, game explanation, reusable copy, display materials, and evidence status.
|
||||
`],
|
||||
['RELEASE_CHECKLIST.md', `# Release Checklist
|
||||
|
||||
## Build
|
||||
|
||||
- [ ] \`pnpm install\`
|
||||
- [ ] \`pnpm build\`
|
||||
- [ ] \`pnpm preview\`
|
||||
- [ ] Production preview opens in a real browser
|
||||
- [ ] Browser console has no new errors
|
||||
|
||||
## Gameplay QA
|
||||
|
||||
- [ ] Win condition works
|
||||
- [ ] Lose condition works
|
||||
- [ ] Restart path works
|
||||
- [ ] Controls feel responsive
|
||||
- [ ] Core loop can be completed and restarted twice
|
||||
- [ ] Resize keeps the game playable
|
||||
|
||||
## Open Issues
|
||||
|
||||
- TODO
|
||||
`],
|
||||
[PRODUCT_OVERVIEW_FILENAME, buildInitialProductOverviewDocument()],
|
||||
['index.html', `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${packageName}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
`],
|
||||
['package.json', `${JSON.stringify({
|
||||
name: packageName,
|
||||
private: true,
|
||||
version: '0.0.0',
|
||||
type: 'module',
|
||||
scripts: {
|
||||
dev: 'vite --host 0.0.0.0',
|
||||
build: 'tsc && vite build',
|
||||
preview: 'vite preview --host 0.0.0.0',
|
||||
},
|
||||
dependencies: {
|
||||
phaser: '3.90.0',
|
||||
},
|
||||
devDependencies: {
|
||||
typescript: '^5.8.3',
|
||||
vite: '^7.0.0',
|
||||
},
|
||||
}, null, 2)}
|
||||
`],
|
||||
['src/main.ts', `import Phaser from 'phaser';
|
||||
import './styles.css';
|
||||
|
||||
class StarterScene extends Phaser.Scene {
|
||||
private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;
|
||||
private player!: Phaser.GameObjects.Rectangle;
|
||||
private goal!: Phaser.GameObjects.Rectangle;
|
||||
private obstacles!: Phaser.GameObjects.Group;
|
||||
private statusText!: Phaser.GameObjects.Text;
|
||||
private finished = false;
|
||||
|
||||
constructor() {
|
||||
super('starter-scene');
|
||||
}
|
||||
|
||||
create() {
|
||||
this.finished = false;
|
||||
const { width, height } = this.scale;
|
||||
|
||||
this.add.text(24, 20, 'Starter example', {
|
||||
fontFamily: 'Arial, sans-serif',
|
||||
fontSize: '24px',
|
||||
color: '#f8fafc',
|
||||
});
|
||||
this.add.text(24, 52, 'Replace this with the game from GDD.md.', {
|
||||
fontFamily: 'Arial, sans-serif',
|
||||
fontSize: '14px',
|
||||
color: '#cbd5e1',
|
||||
});
|
||||
|
||||
this.player = this.add.rectangle(84, height / 2, 28, 28, 0x38bdf8);
|
||||
this.goal = this.add.rectangle(width - 84, height / 2, 32, 96, 0x22c55e);
|
||||
this.obstacles = this.add.group([
|
||||
this.add.rectangle(width * 0.42, height * 0.3, 34, 160, 0xf97316),
|
||||
this.add.rectangle(width * 0.58, height * 0.7, 34, 160, 0xf97316),
|
||||
]);
|
||||
this.statusText = this.add.text(24, height - 48, 'Use arrow keys to dodge obstacles.', {
|
||||
fontFamily: 'Arial, sans-serif',
|
||||
fontSize: '16px',
|
||||
color: '#f8fafc',
|
||||
});
|
||||
|
||||
this.cursors = this.input.keyboard?.createCursorKeys() ?? {};
|
||||
}
|
||||
|
||||
update(_time: number, delta: number) {
|
||||
if (this.finished) {
|
||||
if (Phaser.Input.Keyboard.JustDown(this.cursors.space!)) {
|
||||
this.scene.restart();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const speedPerSecond = 240;
|
||||
const dt = Math.min(delta, 50) / 1000;
|
||||
if (this.cursors.left?.isDown) this.player.x -= speedPerSecond * dt;
|
||||
if (this.cursors.right?.isDown) this.player.x += speedPerSecond * dt;
|
||||
if (this.cursors.up?.isDown) this.player.y -= speedPerSecond * dt;
|
||||
if (this.cursors.down?.isDown) this.player.y += speedPerSecond * dt;
|
||||
|
||||
this.player.x = Phaser.Math.Clamp(this.player.x, 20, this.scale.width - 20);
|
||||
this.player.y = Phaser.Math.Clamp(this.player.y, 96, this.scale.height - 20);
|
||||
|
||||
const playerBounds = this.player.getBounds();
|
||||
const hitObstacle = this.obstacles.getChildren().some((obstacle) => (
|
||||
Phaser.Geom.Intersects.RectangleToRectangle(
|
||||
playerBounds,
|
||||
(obstacle as Phaser.GameObjects.Rectangle).getBounds(),
|
||||
)
|
||||
));
|
||||
|
||||
if (hitObstacle) {
|
||||
this.finish('You hit an obstacle. Press space to retry.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Phaser.Geom.Intersects.RectangleToRectangle(playerBounds, this.goal.getBounds())) {
|
||||
this.finish('Goal reached. Press space to play again.');
|
||||
}
|
||||
}
|
||||
|
||||
private finish(message: string) {
|
||||
this.finished = true;
|
||||
this.statusText.setText(message);
|
||||
this.player.setFillStyle(0xf8fafc);
|
||||
}
|
||||
}
|
||||
|
||||
new Phaser.Game({
|
||||
type: Phaser.AUTO,
|
||||
parent: 'app',
|
||||
width: 960,
|
||||
height: 540,
|
||||
scale: {
|
||||
mode: Phaser.Scale.FIT,
|
||||
autoCenter: Phaser.Scale.CENTER_BOTH,
|
||||
},
|
||||
backgroundColor: '#0f172a',
|
||||
scene: StarterScene,
|
||||
});
|
||||
`],
|
||||
['src/styles.css', `:root {
|
||||
color: #e2e8f0;
|
||||
background: #020617;
|
||||
font-family: Inter, Arial, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
}
|
||||
`],
|
||||
['tsconfig.json', `${JSON.stringify({
|
||||
compilerOptions: {
|
||||
target: 'ES2020',
|
||||
useDefineForClassFields: true,
|
||||
module: 'ESNext',
|
||||
moduleResolution: 'Bundler',
|
||||
lib: ['ES2020', 'DOM', 'DOM.Iterable'],
|
||||
strict: true,
|
||||
skipLibCheck: true,
|
||||
noEmit: true,
|
||||
},
|
||||
include: ['src'],
|
||||
}, null, 2)}
|
||||
`],
|
||||
['vite.config.ts', `import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
preview: {
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
});
|
||||
`],
|
||||
];
|
||||
}
|
||||
|
||||
async function listRootEntries(projectPath: string): Promise<string[]> {
|
||||
const entries = await readdir(projectPath, { withFileTypes: true });
|
||||
return entries
|
||||
.map((entry) => entry.name)
|
||||
.filter((name) => !IGNORABLE_ROOT_ENTRIES.has(name))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
async function listUnexpectedEntries(
|
||||
projectPath: string,
|
||||
currentPath = projectPath,
|
||||
): Promise<string[]> {
|
||||
const entries = await readdir(currentPath, { withFileTypes: true });
|
||||
const unexpected: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(currentPath, entry.name);
|
||||
const relativePath = relative(projectPath, fullPath).split('\\').join('/');
|
||||
|
||||
if (currentPath === projectPath && IGNORABLE_ROOT_ENTRIES.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isAllowedDirectory = entry.isDirectory() && PHASER_SCAFFOLD_ALLOWED_DIRECTORY_PATHS.has(relativePath);
|
||||
const isAllowedFile = entry.isFile() && PHASER_SCAFFOLD_ALLOWED_FILE_PATHS.has(relativePath);
|
||||
if (!isAllowedDirectory && !isAllowedFile) {
|
||||
if (entry.isDirectory()) {
|
||||
const nestedUnexpected = await listUnexpectedEntries(projectPath, fullPath);
|
||||
unexpected.push(...(nestedUnexpected.length > 0 ? nestedUnexpected : [relativePath]));
|
||||
} else {
|
||||
unexpected.push(relativePath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isAllowedDirectory) {
|
||||
unexpected.push(...await listUnexpectedEntries(projectPath, fullPath));
|
||||
}
|
||||
}
|
||||
|
||||
return unexpected.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
async function listExistingScaffoldFiles(projectPath: string): Promise<string[]> {
|
||||
const existing: string[] = [];
|
||||
for (const relativePath of PHASER_SCAFFOLD_FILES) {
|
||||
try {
|
||||
await stat(join(projectPath, relativePath));
|
||||
existing.push(relativePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
export async function maybeWriteProjectScaffold(
|
||||
projectPath: string,
|
||||
snapshot: ProjectTemplateSnapshot,
|
||||
): Promise<ProjectScaffoldResult> {
|
||||
if (snapshot.capabilities.scaffold?.enabled !== true) {
|
||||
return { status: 'disabled' };
|
||||
}
|
||||
|
||||
if (snapshot.capabilities.scaffold.kind !== 'phaser-2d-casual') {
|
||||
return { status: 'disabled' };
|
||||
}
|
||||
|
||||
const rootEntries = await listRootEntries(projectPath);
|
||||
const nonScaffoldEntries = rootEntries.filter((entry) => !PHASER_SCAFFOLD_ROOTS.has(entry));
|
||||
if (nonScaffoldEntries.length > 0) {
|
||||
return { status: 'skipped_non_empty', entries: nonScaffoldEntries };
|
||||
}
|
||||
|
||||
const unexpectedEntries = await listUnexpectedEntries(projectPath);
|
||||
if (unexpectedEntries.length > 0) {
|
||||
return { status: 'skipped_non_empty', entries: unexpectedEntries };
|
||||
}
|
||||
|
||||
const existingFiles = await listExistingScaffoldFiles(projectPath);
|
||||
if (existingFiles.length > 0) {
|
||||
return { status: 'skipped_existing_files', files: existingFiles };
|
||||
}
|
||||
|
||||
const scaffoldFiles = buildPhaserScaffoldFiles(projectPath);
|
||||
for (const [relativePath, content] of scaffoldFiles) {
|
||||
const filePath = join(projectPath, relativePath);
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
try {
|
||||
await writeFile(filePath, content, {
|
||||
encoding: 'utf8',
|
||||
flag: 'wx',
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
return { status: 'skipped_existing_files', files: [relativePath] };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'generated',
|
||||
files: [...PHASER_SCAFFOLD_FILES],
|
||||
};
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import {
|
||||
validateProjectTemplateSnapshot,
|
||||
type ProjectTemplateSnapshot,
|
||||
} from '../../shared/project-template';
|
||||
|
||||
export const PROJECT_TEMPLATE_SNAPSHOT_PATH = '.niancode/project-template.json';
|
||||
|
||||
export type ProjectTemplateSnapshotReadResult =
|
||||
| { status: 'missing' }
|
||||
| { status: 'valid'; snapshot: ProjectTemplateSnapshot }
|
||||
| { status: 'invalid'; error: string };
|
||||
|
||||
export function getProjectTemplateSnapshotPath(projectPath: string): string {
|
||||
return join(projectPath, '.niancode', 'project-template.json');
|
||||
}
|
||||
|
||||
export async function readProjectTemplateSnapshot(projectPath: string): Promise<ProjectTemplateSnapshotReadResult> {
|
||||
const snapshotPath = getProjectTemplateSnapshotPath(projectPath);
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(snapshotPath, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { status: 'missing' };
|
||||
}
|
||||
return { status: 'invalid', error: `${PROJECT_TEMPLATE_SNAPSHOT_PATH}: ${String(error)}` };
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
return { status: 'invalid', error: `${PROJECT_TEMPLATE_SNAPSHOT_PATH}: invalid JSON (${String(error)})` };
|
||||
}
|
||||
|
||||
const validation = validateProjectTemplateSnapshot(parsed);
|
||||
if (!validation.ok) {
|
||||
return { status: 'invalid', error: `${PROJECT_TEMPLATE_SNAPSHOT_PATH}: ${validation.error}` };
|
||||
}
|
||||
return { status: 'valid', snapshot: validation.snapshot };
|
||||
}
|
||||
|
||||
export async function writeProjectTemplateSnapshot(
|
||||
projectPath: string,
|
||||
snapshot: ProjectTemplateSnapshot,
|
||||
): Promise<ProjectTemplateSnapshot> {
|
||||
const validation = validateProjectTemplateSnapshot(snapshot);
|
||||
if (!validation.ok) {
|
||||
throw new Error(validation.error);
|
||||
}
|
||||
const snapshotPath = getProjectTemplateSnapshotPath(projectPath);
|
||||
await mkdir(dirname(snapshotPath), { recursive: true });
|
||||
try {
|
||||
await writeFile(snapshotPath, `${JSON.stringify(validation.snapshot, null, 2)}\n`, {
|
||||
encoding: 'utf8',
|
||||
flag: 'wx',
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
throw new Error(`${PROJECT_TEMPLATE_SNAPSHOT_PATH} already exists and cannot be replaced`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return validation.snapshot;
|
||||
}
|
||||
@@ -29,11 +29,6 @@ export interface EnsureBundledCourseSkillsOptions {
|
||||
sourceDir?: string;
|
||||
}
|
||||
|
||||
export interface EnsureBundledCourseAgentsOptions {
|
||||
managedConfigDir: string;
|
||||
sourceDir?: string;
|
||||
}
|
||||
|
||||
const WRAPPER_PLUGIN_NAME = 'superpowers-niancode.js';
|
||||
const SUPERPOWERS_REPO_DIR = 'superpowers';
|
||||
const SUPERPOWERS_BUNDLES_DIR = 'superpowers-bundles';
|
||||
@@ -68,12 +63,6 @@ export function resolveBundledCourseSkillsDir(input: BundledSuperpowersPathInput
|
||||
: join(input.appPath, '.opencode', 'skills');
|
||||
}
|
||||
|
||||
export function resolveBundledCourseAgentsDir(input: BundledSuperpowersPathInput): string {
|
||||
return input.isPackaged
|
||||
? join(input.resourcesPath, 'course-agents')
|
||||
: join(input.appPath, '.opencode', 'agent');
|
||||
}
|
||||
|
||||
export function getManagedOpencodeConfigDir(userDataDir: string): string {
|
||||
return join(userDataDir, 'opencode', 'niancode-config');
|
||||
}
|
||||
@@ -277,16 +266,3 @@ export function ensureBundledCourseSkills(options: EnsureBundledCourseSkillsOpti
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function ensureBundledCourseAgents(options: EnsureBundledCourseAgentsOptions): boolean {
|
||||
const sourceDir = options.sourceDir?.trim();
|
||||
if (!sourceDir || !existsSync(sourceDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetAgentDir = join(options.managedConfigDir, 'agent');
|
||||
mkdirSync(targetAgentDir, { recursive: true });
|
||||
copyDirectorySync(sourceDir, targetAgentDir);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user