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.
142 lines (126 loc) • 5.64 kB
text/typescript
import fs from 'fs';
import path from 'path';
import { CrewEntry } from './config';
import { projectAgentFolder, rootPath } from './vault';
import { normalizeRoleName, roleDefinitionsForCrew } from './role';
export interface CrewTask {
id?: string;
text: string;
done: boolean;
file: string;
line: number;
scope: string;
fields: { [key: string]: string };
}
export interface CrewSummary {
total: number;
done: number;
open: number;
blocked: number;
doing: number;
}
export function scanTasks(crew: CrewEntry): CrewTask[] {
const files = taskFiles(crew);
const tasks: CrewTask[] = [];
for (const item of files) tasks.push(...parseTasks(item.file, item.scope));
return tasks;
}
export function parseTasks(file: string, scope: string): CrewTask[] {
if (!fs.existsSync(file)) return [];
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
const tasks: CrewTask[] = [];
for (let i = 0; i < lines.length; i++) {
const parsed = parseTaskLine(lines[i]);
if (parsed) tasks.push({ ...parsed, file, line: i + 1, scope });
}
return tasks;
}
export function summarize(tasks: CrewTask[]): CrewSummary {
let done = 0, blocked = 0, doing = 0;
for (const t of tasks) {
if (t.done) done++;
const status = (t.fields.status || '').toLowerCase();
if (status === 'blocked') blocked++;
if (status === 'doing' || status === 'in-progress') doing++;
}
return { total: tasks.length, done, open: tasks.length - done, blocked, doing };
}
export function openPendingQuestions(crew: CrewEntry): CrewTask[] {
return parseTasks(rootPath(crew, 'Pending-Questions.md'), 'pending').filter(t => !t.done);
}
export function taskProgress(tasks: CrewTask[]): number {
if (tasks.length === 0) return 0;
return Math.round((summarize(tasks).done / tasks.length) * 100);
}
function parseTaskLine(line: string): Omit<CrewTask, 'file' | 'line' | 'scope'> | undefined {
const match = line.match(/^\s*-\s+\[([ xX-])\]\s+(.+)$/);
if (!match) return undefined;
const done = match[1].toLowerCase() === 'x';
const raw = match[2].trim();
const fields: { [key: string]: string } = {};
const fieldRe = /\[([A-Za-z0-9_-]+)::\s*([^\]]+)\]/g;
let f: RegExpExecArray | null;
while ((f = fieldRe.exec(raw))) fields[f[1]] = f[2].trim();
const idMatch = raw.match(/\b(?:AT|D|Q|A|R)-\d{6}-\d{3}\b/);
return { id: idMatch ? idMatch[0] : undefined, text: raw, done, fields };
}
function taskFiles(crew: CrewEntry): { file: string; scope: string }[] {
const files = [
{ file: rootPath(crew, 'Pending-Questions.md'), scope: 'pending' },
];
const roles = roleDefinitionsForCrew(crew);
// Backward compatibility: old crew roots may still contain global role
// task files. New init writes role descriptions only and scopes concrete
// work under Projects/<project>/Agents/<role>/Tasks.md.
for (const role of roles) {
const legacy = rootPath(crew, path.join(role.folder, 'Tasks.md'));
if (fs.existsSync(legacy)) files.push({ file: legacy, scope: `legacy-role:${role.name}` });
}
const projectsDir = rootPath(crew, 'Projects');
const projectNames = configuredProjectNames(crew, projectsDir);
for (const name of projectNames) {
const base = path.join(projectsDir, name);
const file = path.join(base, 'Tasks.md');
if (fs.existsSync(file)) files.push({ file, scope: `project:${name}` });
files.push(...tasklistFiles(path.join(base, 'Tasklists', 'active'), `project:${name}:tasklist`));
const seenRoleFolders = new Set<string>();
for (const role of roles) {
const folder = projectAgentFolder(role);
seenRoleFolders.add(folder);
const roleFile = path.join(base, 'Agents', folder, 'Tasks.md');
if (fs.existsSync(roleFile)) files.push({ file: roleFile, scope: `project:${name}:role:${role.name}` });
files.push(...tasklistFiles(path.join(base, 'Agents', folder, 'Tasklists', 'active'), `project:${name}:role:${role.name}:tasklist`));
}
const agentsDir = path.join(base, 'Agents');
if (fs.existsSync(agentsDir)) {
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
if (!entry.isDirectory() || seenRoleFolders.has(entry.name)) continue;
const roleFile = path.join(agentsDir, entry.name, 'Tasks.md');
const roleName = normalizeRoleName(entry.name);
if (fs.existsSync(roleFile)) files.push({ file: roleFile, scope: `project:${name}:role:${roleName}` });
files.push(...tasklistFiles(path.join(agentsDir, entry.name, 'Tasklists', 'active'), `project:${name}:role:${roleName}:tasklist`));
}
}
}
return files.filter(item => fs.existsSync(item.file));
}
function configuredProjectNames(crew: CrewEntry, projectsDir: string): string[] {
if (crew.projects && crew.projects.length > 0) return crew.projects.map(p => p.name);
if (!fs.existsSync(projectsDir)) return [];
return fs.readdirSync(projectsDir, { withFileTypes: true })
.filter(entry => entry.isDirectory() && !entry.name.startsWith('_') && !entry.name.startsWith('.'))
.map(entry => entry.name);
}
function tasklistFiles(dir: string, scope: string): { file: string; scope: string }[] {
if (!fs.existsSync(dir)) return [];
const files: { file: string; scope: string }[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...tasklistFiles(full, scope));
} else if (entry.isFile() && entry.name.endsWith('.md')) {
files.push({ file: full, scope });
}
}
return files.sort((a, b) => a.file.localeCompare(b.file));
}