276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
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<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 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',
|
||
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<ProjectConfig>;
|
||
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<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 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<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');
|
||
}));
|
||
}
|
||
|
||
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<ProjectConfig> {
|
||
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<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,
|
||
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<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));
|
||
}
|