rigjs
Version:
A multi-repos dev tool based on yarn and git.Rigjs is intended to be the simplest way to develop,share and deliver codes between different developers or different projects.
340 lines (308 loc) • 16.9 kB
text/typescript
import fs from 'fs';
import path from 'path';
import { CrewEntry, CrewProject, DEFAULT_CREW_ROOT } from './config';
import { CrewRoleDefinition, roleDefinitionsForCrew } from './role';
export function crewRoot(crew: CrewEntry): string {
return path.join(crew.vault, crew.root || DEFAULT_CREW_ROOT);
}
export function crewPath(crew: CrewEntry, rel: string): string {
return path.join(crew.vault, rel);
}
export function rootPath(crew: CrewEntry, rel: string): string {
return path.join(crewRoot(crew), rel);
}
export function ensureCrewVault(crew: CrewEntry): void {
ensureDir(crew.vault);
ensureDir(crewPath(crew, 'tmp'));
writeIfMissing(crewPath(crew, 'tmp/.gitkeep'), '');
fs.mkdirSync(crewRoot(crew), { recursive: true });
writeIfMissing(rootPath(crew, 'Current-Goal.md'), '# Current Goal\n\n');
writeIfMissing(rootPath(crew, 'Dashboard.md'), '# Dashboard\n\nCoding agents can run `rig orchestrate board` to refresh this dashboard.\n');
writeIfMissing(rootPath(crew, 'Pending-Questions.md'), '# Pending Questions\n\nSystem→user questions the Orchestrator needs answered. Answer via the overmind `inbox/` (or reply in chat); the Orchestrator then resolves the matching item here.\n\n## Open\n\n## Resolved\n');
ensureDir(rootPath(crew, 'Shared'));
writeIfMissing(rootPath(crew, 'Shared/Spec.md'), '# Spec\n\n');
writeIfMissing(rootPath(crew, 'Shared/Decisions.md'), '# Decisions\n\n');
writeIfMissing(rootPath(crew, 'Shared/Glossary.md'), '# Glossary\n\n');
writeIfMissing(rootPath(crew, 'Shared/Context.md'), '# Context\n\n');
ensureDir(rootPath(crew, 'Shared/PRDs'));
writeIfMissing(rootPath(crew, 'Shared/PRDs/.gitkeep'), '');
const roles = roleDefinitionsForCrew(crew);
for (const role of roles) ensureRole(crew, role);
writeRoleRegistry(crew, roles);
ensureDir(rootPath(crew, 'Projects'));
for (const project of crew.projects || []) ensureProject(crew, project);
ensureDir(rootPath(crew, 'Logs'));
writeVaultAgentInstructions(crew);
}
export function ensureProject(crew: CrewEntry, project: CrewProject): void {
const base = rootPath(crew, path.join('Projects', project.name));
ensureDir(base);
writeProjectOwnerFile(path.join(base, 'Owner.md'), project);
writeIfMissing(path.join(base, 'Context.md'), `# ${project.name} Context\n\n`);
writeIfMissing(path.join(base, 'Tasks.md'), renderProjectTasksFile(project));
writeIfMissing(path.join(base, 'Pending-Questions.md'), renderProjectPendingFile(project));
ensureTasklists(base);
writeIfMissing(path.join(base, 'Decisions.md'), `# ${project.name} Decisions\n\n`);
writeIfMissing(path.join(base, 'Test-Plan.md'), `# ${project.name} Test Plan\n\n`);
ensureDir(path.join(base, 'Agents'));
for (const role of roleDefinitionsForCrew(crew)) ensureProjectAgentTasks(base, project, role);
ensureDir(path.join(base, 'PRDs'));
writeIfMissing(path.join(base, 'PRDs', '.gitkeep'), '');
ensureDir(path.join(base, 'Reports'));
writeIfMissing(path.join(base, 'Reports', '.gitkeep'), '');
}
export function readText(file: string): string {
if (!fs.existsSync(file)) return '';
return fs.readFileSync(file, 'utf8');
}
export function writeText(file: string, content: string): void {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content, 'utf8');
}
export function appendLog(crew: CrewEntry, message: string): void {
const d = new Date();
const name = d.toISOString().slice(0, 10) + '.md';
const file = rootPath(crew, path.join('Logs', name));
if (!fs.existsSync(file)) writeText(file, `# ${name.slice(0, 10)}\n\n`);
fs.appendFileSync(file, `- ${d.toISOString()} ${message}\n`, 'utf8');
}
function ensureRole(crew: CrewEntry, role: CrewRoleDefinition): void {
const folder = role.folder;
const base = rootPath(crew, folder);
ensureDir(base);
writeIfMissing(path.join(base, 'Role.md'), renderRoleFile(role));
if (role.name === 'orchestrator') {
ensureDir(path.join(base, 'Reports'));
writeIfMissing(path.join(base, 'Reports', '.gitkeep'), '');
}
if (role.name === 'researcher') {
ensureDir(path.join(base, 'Reports'));
writeIfMissing(path.join(base, 'Reports', '.gitkeep'), '');
writeIfMissing(path.join(base, 'Index.md'), '# Research Index\n\n');
}
if (!role.builtIn) {
ensureDir(path.join(base, 'Reports'));
writeIfMissing(path.join(base, 'Reports', '.gitkeep'), '');
}
}
function writeRoleRegistry(crew: CrewEntry, roles: CrewRoleDefinition[]): void {
const file = rootPath(crew, 'Shared/Roles.md');
cleanupLegacyRoleRegistry(file);
const rows = roles.map(role => {
const prompt = role.promptPath ? role.promptPath.replace(crewPathForHome(), '$RIG_HOME') : '-';
return `| ${role.name} | ${role.title} | ${role.agent || '-'} | ${role.defaultExecutor || '-'} | ${role.folder} | ${prompt} |`;
});
upsertManagedBlock(file, [
'<!-- rig-crew-roles:start -->',
'## Generated Role Registry',
'',
'Generated by `rig crew`. Edit global custom roles under `$RIG_HOME/crew/roles/` (`~/.rig/crew/roles/` by default). Add human notes outside this managed block.',
'',
'| Role | Title | Agent | Executor | Vault Folder | Prompt |',
'|---|---|---|---|---|---|',
...rows,
'',
'<!-- rig-crew-roles:end -->',
].join('\n'), '# Crew Role Registry\n\n');
}
function cleanupLegacyRoleRegistry(file: string): void {
if (!fs.existsSync(file)) return;
const current = readText(file);
if (!current.includes('Generated by `rig crew`.')) return;
if (!current.includes('| Role | Title | Agent | Executor | Vault Folder | Prompt |')) return;
const marker = '<!-- rig-crew-roles:start -->';
const markerIndex = current.indexOf(marker);
const before = markerIndex >= 0 ? current.slice(0, markerIndex) : current;
const after = markerIndex >= 0 ? current.slice(markerIndex) : '';
const lines = before.split(/\r?\n/);
const generated = lines.findIndex(line => line.startsWith('Generated by `rig crew`.'));
const table = lines.findIndex((line, i) => i > generated && line.startsWith('| Role | Title | Agent | Executor | Vault Folder | Prompt |'));
if (generated < 0 || table < 0) return;
let end = table + 1;
while (end < lines.length && lines[end].trim().startsWith('|')) end++;
while (end < lines.length && lines[end].trim() === '') end++;
const cleanedBefore = lines.slice(0, generated).concat(lines.slice(end)).join('\n').trimEnd();
const next = [cleanedBefore, after.trimStart()].filter(Boolean).join('\n\n').trimEnd() + '\n';
if (next !== current) writeText(file, next);
}
function renderRoleFile(role: CrewRoleDefinition): string {
return [
`# ${role.title}`,
'',
`Role: \`${role.name}\``,
`Agent: \`${role.agent || '-'}\``,
`Default executor: \`${role.defaultExecutor || '-'}\``,
`Prompt: \`${role.promptPath || '-'}\``,
'',
'## Purpose',
'',
role.description || '',
'',
'## Task Location',
'',
'This role is reusable across projects. Do not keep normal project work here.',
'Assign concrete work under `Projects/<project>/Agents/<role>/Tasks.md`, or under `Projects/<project>/Tasks.md` for project-owner work.',
'',
'Users may edit this file to tune the role.',
'',
].join('\n');
}
function ensureProjectAgentTasks(base: string, project: CrewProject, role: CrewRoleDefinition): void {
const dir = path.join(base, 'Agents', projectAgentFolder(role));
ensureDir(dir);
writeIfMissing(path.join(dir, 'Tasks.md'), renderProjectAgentTasksFile(project, role));
ensureTasklists(dir);
}
export function projectAgentFolder(role: CrewRoleDefinition): string {
if (role.builtIn && !role.folder.includes(path.sep)) return role.folder;
return role.name;
}
function renderProjectTasksFile(project: CrewProject): string {
return [
`# ${project.name} Project Tasks`,
'',
'Keep this file short: current project-owner tasks and cross-role coordination only.',
'For larger plans, split current work into `Tasklists/active/<feature-or-iteration>.md`.',
'Move completed or stale batches to `Tasklists/archive/YYYY-MM.md`; archive files are not scanned for the active dashboard.',
'Role-specific current work belongs in `Agents/<role>/Tasks.md` or `Agents/<role>/Tasklists/active/*.md`.',
'',
].join('\n');
}
function renderProjectPendingFile(project: CrewProject): string {
return [
`# ${project.name} Pending Questions`,
'',
'<!-- rig-crew-pending:v1 -->',
'',
'Materials / facts / decisions the user must supply before the crew can proceed.',
'Add with `rig crew pending add "<title>" --project <name>`; resolve with `rig crew pending answer <id> --note "..."`.',
'',
'## Open',
'',
'_No open questions._',
'',
'## Resolved',
'',
'_No resolved questions yet._',
'',
].join('\n');
}
function renderProjectAgentTasksFile(project: CrewProject, role: CrewRoleDefinition): string {
return [
`# ${project.name} ${role.title} Tasks`,
'',
`Use this file for the short current ${role.title} queue scoped to \`${project.name}\`.`,
'For larger batches, split current work into `Tasklists/active/<feature-or-iteration>.md`.',
'Move completed or stale batches to `Tasklists/archive/YYYY-MM.md`; archive files are not scanned for the active dashboard.',
`Default inline fields: [project:: ${project.name}] [role:: ${role.name}] [owner:: ${project.owner || `maintainer:${project.name}`}] [status:: pending]`,
'',
].join('\n');
}
function ensureTasklists(base: string): void {
ensureDir(path.join(base, 'Tasklists', 'active'));
writeIfMissing(path.join(base, 'Tasklists', 'active', '.gitkeep'), '');
ensureDir(path.join(base, 'Tasklists', 'archive'));
writeIfMissing(path.join(base, 'Tasklists', 'archive', '.gitkeep'), '');
}
function crewPathForHome(): string {
return process.env.RIG_HOME || path.join(process.env.HOME || '', '.rig');
}
const AGENT_RULES_START = '<!-- rig-crew:start -->';
const AGENT_RULES_END = '<!-- rig-crew:end -->';
function writeVaultAgentInstructions(crew: CrewEntry): void {
const targets = ['CLAUDE.md', 'AGENTS.md'].map(file => crewPath(crew, file));
const seen = new Set<string>();
for (const file of targets) {
const key = fs.existsSync(file) ? fs.realpathSync(file) : file;
if (seen.has(key)) continue;
seen.add(key);
upsertManagedBlock(file, renderVaultAgentInstructions(crew));
}
}
function upsertManagedBlock(file: string, block: string, missingContent?: string): void {
const current = fs.existsSync(file) ? readText(file) : (missingContent || `# ${path.basename(file, '.md')}\n\n`);
const markers = managedMarkers(block);
const re = new RegExp(`${escapeRegExp(markers.start)}[\\s\\S]*?${escapeRegExp(markers.end)}`);
const next = re.test(current)
? current.replace(re, block.trimEnd())
: `${current.trimEnd()}\n\n${block.trimEnd()}`;
if (next !== current) writeText(file, `${next.trimEnd()}\n`);
}
function managedMarkers(block: string): { start: string; end: string } {
const lines = block.split(/\r?\n/).map(l => l.trim());
const start = lines.find(l => /^<!--\s*[-a-z0-9]+:start\s*-->$/.test(l));
const end = lines.find(l => /^<!--\s*[-a-z0-9]+:end\s*-->$/.test(l));
if (!start || !end) throw new Error('managed block requires start/end markers');
return { start, end };
}
function renderVaultAgentInstructions(crew: CrewEntry): string {
const root = crew.root || DEFAULT_CREW_ROOT;
return [
AGENT_RULES_START,
'## Rig Crew',
'',
'This Vault uses `rig orchestrate` (alias: `rig crew`) as an agent-facing coordination layer. Humans talk to the current Claude/Codex coding session; the coding agent uses `rig orchestrate` and Vault files to communicate with the Orchestrator and coordinate other roles.',
'',
`- Crew root: \`${root}\``,
`- Dashboard: \`${root}/Dashboard.md\``,
`- Pending questions (system→user): \`${root}/Pending-Questions.md\``,
`- Role registry: \`${root}/Shared/Roles.md\``,
`- Reusable role descriptions: \`${root}/<role>/Role.md\` and \`${root}/Roles/<custom-role>/Role.md\``,
`- Project owner memory: \`${root}/Projects/<project>/\``,
`- Per-project pending questions (待补充资料): \`${root}/Projects/<project>/Pending-Questions.md\` — managed by \`rig orchestrate pending\``,
`- Project-scoped agent tasks: \`${root}/Projects/<project>/Agents/<role>/Tasks.md\``,
`- Large active task batches: \`${root}/Projects/<project>/Tasklists/active/*.md\` and \`${root}/Projects/<project>/Agents/<role>/Tasklists/active/*.md\`. Keep \`Tasks.md\` short; archived tasklists are not part of the active dashboard.`,
'- Vault-local scratch projects belong under `tmp/<project>/`.',
'- User-level rules, test accounts, custom roles, and research output policy live under `~/.rig/`.',
'- Coordinate through Vault files; do not start or assume a separate multi-agent runtime inside a project repo.',
'- Do not copy secrets, auth state, production traces, or personal data into project repositories.',
'- When working on project code, also read that project\'s `RIG.md` or `rig.md` if present.',
'',
'Default collaboration protocol for coding agents:',
'',
'0. Do not treat `rig orchestrate` as a human-facing command workflow. If you can run the command or update the Vault files yourself, do it instead of asking the human to run it.',
'1. If the user asks for planning, multi-agent coordination, PRD, research, testing strategy, project owner work, role routing, reports, or broad project changes, hand the request to the Orchestrator first.',
`2. Preferred handoff: run \`rig orchestrate "<user request>"\`, then read \`${root}/Dashboard.md\`, \`${root}/Pending-Questions.md\`, and \`${root}/Shared/Roles.md\`.`,
`3. Maintain status awareness before and after work by checking \`${root}/Dashboard.md\`, \`${root}/Pending-Questions.md\`, \`${root}/Shared/Roles.md\`, project \`Tasks.md\`, project agent \`Agents/<role>/Tasks.md\`, and active tasklists.`,
`4. If the CLI is unavailable, append the request to \`${root}/Current-Goal.md\`; when a project is known, route small/current work to \`${root}/Projects/<project>/Tasks.md\` or \`${root}/Projects/<project>/Agents/<role>/Tasks.md\`, and route larger batches to \`Tasklists/active/<feature-or-iteration>.md\`; then refresh the dashboard when possible.`,
'5. Treat the Orchestrator as the default orchestration prompt/protocol, not as a required Claude/Codex subagent. Subagents may be used as optional executors for specific roles, but Vault files are the source of truth.',
'6. The Orchestrator communicates with other roles through Markdown tasks and delegation packets, not private chat state. Use `[role:: <role>]`, `[owner:: <owner>]`, `[project:: <project>]`, `[executor:: <executor>]`, and status fields in the relevant project-scoped `Tasks.md`.',
`7. Worker results must be written back to the relevant role/project files under \`${root}/\`; user-facing questions go to \`${root}/Pending-Questions.md\` for the Orchestrator to surface.`,
`8. Whenever a project is blocked by missing user-supplied material (API key, screenshot, vendor decision, sample data), record it with \`rig crew pending add ...\` rather than silently asking the human; when the user supplies it, the agent itself calls \`rig crew pending answer <id> --note "<summary>"\` and continues. Check \`rig crew pending --project <name>\` before starting any new tick on that project.`,
'',
AGENT_RULES_END,
].join('\n');
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function writeProjectOwnerFile(file: string, project: CrewProject): void {
const owner = project.owner || `maintainer:${project.name}`;
const fresh = `# ${owner}\n\nProject: ${project.name}\nPath: ${project.path}\n`;
if (!fs.existsSync(file)) {
writeText(file, fresh);
return;
}
const current = readText(file);
let next = current;
if (!next.trim()) next = fresh;
else {
const lines = next.split(/\r?\n/);
if (lines[0].startsWith('# maintainer:') || lines[0] === `# ${owner}`) lines[0] = `# ${owner}`;
next = lines.join('\n');
next = upsertLine(next, /^Project:\s*.*$/m, `Project: ${project.name}`);
next = upsertLine(next, /^Path:\s*.*$/m, `Path: ${project.path}`);
}
if (next !== current) writeText(file, next);
}
function upsertLine(text: string, pattern: RegExp, line: string): string {
if (pattern.test(text)) return text.replace(pattern, line);
return text.endsWith('\n') ? `${text}${line}\n` : `${text}\n${line}\n`;
}
function ensureDir(dir: string): void {
fs.mkdirSync(dir, { recursive: true });
}
function writeIfMissing(file: string, content: string): void {
if (fs.existsSync(file)) return;
writeText(file, content);
}