import path from 'node:path'; import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; import { createProjectConfig, validateAgentConfigs, validateAgentNames, type ProjectAgentConfig, type ProjectConfig, } from '../../shared/project-config'; export const PROJECT_CONFIG_PATH = '.niancode/project.json'; 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; 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 rawResponsibility = raw.responsibility; const responsibility = rawResponsibility && typeof rawResponsibility === 'object' ? rawResponsibility as Partial : {}; 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, // 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: raw.enabled !== false, model: typeof raw.model === 'string' && raw.model.trim() ? raw.model.trim() : null, skillIds: 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() : '', 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'); } const raw = value as Partial; if (raw.schemaVersion !== 1) throw new Error('Unsupported project config schema'); 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)); 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, initialized, superpowersEnabled: raw.superpowersEnabled === true, 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 { 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 responsibility = current.responsibility.mission.trim() ? [ '## 你的职责', current.responsibility.mission, '', '## 负责内容', ...current.responsibility.owns.map((item) => `- ${item}`), '', '## 工作边界', ...current.responsibility.boundaries.map((item) => `- ${item}`), '', '## 工作原则', ...current.responsibility.principles.map((item) => `- ${item}`), '', ] : []; return [ `# ${current.name} · ${current.roleName}`, '', `你的名字是「${current.name}」,在与用户对话和介绍自己时使用这个名字。`, `你在当前项目中的职能是「${current.roleName}」。`, `你的稳定角色 ID 是 \`${current.id}\`。你只属于当前项目。`, '', current.prompt.trim(), ...responsibility, '请尊重用户已有文件和未提交改动;遇到不确定的高影响决策时先说明影响。', '', '## 项目伙伴', ...(peers.length > 0 ? peers.map((peer) => `- ${peer.name}(${peer.roleName},${peer.id})`) : ['- 暂无其他项目伙伴。']), '', '项目目录是当前项目的长期上下文。开始工作前读取与任务相关的文件,结束时把实际进展、假设、证据、风险和下一步写入项目文件。', ].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; const prompt = agent.prompt.trim() || buildProjectAgentPrompt(config, agent); 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} --- ${prompt}`; } async function materializeAgents(projectPath: string, config: ProjectConfig): Promise { 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'); })); } 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, options?: { defaultModel?: string | null }, ): Promise { 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(config.createdAt), { encoding: 'utf8', flag: 'wx' }); await writeFile(path.join(projectPath, 'TASKS.md'), initialTaskDocument(), { encoding: 'utf8', flag: 'wx' }); return config; } export async function writeProjectConfig(projectPath: string, value: unknown): Promise { 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, createdAt: previous.config.createdAt, updatedAt: new Date().toISOString(), }); if (config.initialized) { 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'); return config; } export async function listProjectKnowledge(projectPath: string): Promise { 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)); }