rigjs 4.0.7 → 4.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/crew/board.ts CHANGED
@@ -78,7 +78,7 @@ function projectTable(crew: ReturnType<typeof requireCrew>, tasks: CrewTask[]):
78
78
  const projects = crew.projects || [];
79
79
  if (projects.length === 0) return '_No projects registered yet._';
80
80
  const rows = projects.map(p => {
81
- const scoped = tasks.filter(t => t.scope !== 'inbox' && (t.scope === `project:${p.name}` || t.fields.project === p.name));
81
+ const scoped = tasks.filter(t => t.scope !== 'inbox' && (t.scope === `project:${p.name}` || t.scope.startsWith(`project:${p.name}:`) || t.fields.project === p.name));
82
82
  const s = summarize(scoped);
83
83
  const health = s.blocked > 0 ? 'At Risk' : 'On Track';
84
84
  return `| ${p.name} | ${p.owner} | ${p.defaultExecutor || crew.defaultExecutor || 'claude'} | ${health} | ${s.open} | ${s.blocked} | ${shortPath(p.path)} |`;
@@ -89,7 +89,7 @@ function projectTable(crew: ReturnType<typeof requireCrew>, tasks: CrewTask[]):
89
89
  function roleTable(crew: ReturnType<typeof requireCrew>, tasks: CrewTask[]): string {
90
90
  const roles = roleDefinitionsForCrew(crew);
91
91
  const rows = roles.map(role => {
92
- const scoped = tasks.filter(t => t.scope === `role:${role.name}` || t.fields.role === role.name || t.fields.owner === role.name);
92
+ const scoped = tasks.filter(t => t.scope === `legacy-role:${role.name}` || t.scope.includes(`:role:${role.name}`) || t.fields.role === role.name || t.fields.owner === role.name);
93
93
  const s = summarize(scoped);
94
94
  return `| ${role.title} | ${taskProgress(scoped)}% | ${s.doing} | ${s.blocked} | ${s.open} |`;
95
95
  });
@@ -111,7 +111,12 @@ function activeTable(tasks: CrewTask[]): string {
111
111
  }
112
112
 
113
113
  function displayScope(scope: string): string {
114
- return scope.startsWith('role:') ? scope.slice('role:'.length) : scope;
114
+ if (scope.startsWith('legacy-role:')) return scope.slice('legacy-role:'.length);
115
+ const projectRole = scope.match(/^project:([^:]+):role:([^:]+)/);
116
+ if (projectRole) return `${projectRole[1]}/${projectRole[2]}`;
117
+ const projectTasklist = scope.match(/^project:([^:]+):tasklist/);
118
+ if (projectTasklist) return projectTasklist[1];
119
+ return scope;
115
120
  }
116
121
 
117
122
  function cleanTaskText(text: string): string {
@@ -10,7 +10,7 @@ interface DoctorOpts { crew?: string; }
10
10
 
11
11
  export default function crewDoctor(opts: DoctorOpts): void {
12
12
  const crew = requireCrew(opts.crew);
13
- const checks: { name: string; ok: boolean; detail: string }[] = [];
13
+ const checks: { name: string; ok: boolean; detail: string; fatal?: boolean }[] = [];
14
14
  checks.push({ name: 'crew config', ok: fs.existsSync(crewPaths.config), detail: shortPath(crewPaths.config) });
15
15
  checks.push({ name: 'vault', ok: fs.existsSync(crew.vault), detail: shortPath(crew.vault) });
16
16
  checks.push({ name: 'crew root', ok: fs.existsSync(rootPath(crew, '')), detail: shortPath(rootPath(crew, '')) });
@@ -27,14 +27,18 @@ export default function crewDoctor(opts: DoctorOpts): void {
27
27
  for (const project of crew.projects || []) {
28
28
  const rig = path.join(project.path, 'RIG.md');
29
29
  const rigLower = path.join(project.path, 'rig.md');
30
- checks.push({ name: `project ${project.name} RIG.md`, ok: fs.existsSync(rig) || fs.existsSync(rigLower), detail: shortPath(rig) });
30
+ checks.push({ name: `project ${project.name} RIG.md`, ok: fs.existsSync(rig) || fs.existsSync(rigLower), detail: shortPath(rig), fatal: false });
31
31
  checks.push({ name: `project ${project.name} path`, ok: fs.existsSync(project.path), detail: shortPath(project.path) });
32
+ checks.push({ name: `project ${project.name} agent tasks`, ok: fs.existsSync(rootPath(crew, path.join('Projects', project.name, 'Agents'))), detail: path.join(crew.root, 'Projects', project.name, 'Agents') });
32
33
  }
33
34
 
34
35
  let failed = 0;
35
36
  for (const c of checks) {
36
37
  if (c.ok) print.succeed(`${c.name}: ${c.detail}`);
37
- else { failed++; print.warn(`${c.name}: missing (${c.detail})`); }
38
+ else {
39
+ if (c.fatal !== false) failed++;
40
+ print.warn(`${c.name}: missing (${c.detail})`);
41
+ }
38
42
  }
39
43
  if (failed > 0) process.exitCode = 1;
40
44
  }
package/lib/crew/index.ts CHANGED
@@ -6,7 +6,7 @@ import crewSync from './sync';
6
6
  import crewDoctor from './doctor';
7
7
  import crewAsk from './ask';
8
8
  import crewStub from './stub';
9
- import { projectAdd, projectList, projectStatus } from './project';
9
+ import { projectAdd, projectList, projectStatus, projectSync } from './project';
10
10
  import { roleAdd, roleList, roleShow } from './roleCommand';
11
11
 
12
12
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -66,6 +66,16 @@ export function registerCrewCommands(program: any): void {
66
66
  .option('--no-write', 'mark owner as read-only')
67
67
  .option('-c, --crew <name>', 'target crew name')
68
68
  .action(projectAdd);
69
+ project.command('sync')
70
+ .description('sync project owners from the vault projects/ directory')
71
+ .option('--from <path>', 'projects directory, relative to the vault (default: projects)')
72
+ .option('--executor <name>', 'default executor for newly discovered projects')
73
+ .option('--test-command <cmd>', 'default focused test command for newly discovered projects')
74
+ .option('--no-write', 'mark newly discovered owners as read-only')
75
+ .option('--keep-missing', 'keep projects that disappeared from the scanned directory')
76
+ .option('--no-archive-missing', 'do not archive stale rig-agents project folders')
77
+ .option('-c, --crew <name>', 'target crew name')
78
+ .action(projectSync);
69
79
  project.command('list')
70
80
  .description('list registered projects')
71
81
  .option('-c, --crew <name>', 'target crew name')
@@ -1,7 +1,8 @@
1
+ import fs from 'fs';
1
2
  import path from 'path';
2
3
  import print from '../print';
3
4
  import { loadCrewConfig, saveCrewConfig, requireCrew, normalizeCrew, CrewProject, shortPath } from './config';
4
- import { ensureProject } from './vault';
5
+ import { ensureProject, rootPath } from './vault';
5
6
  import { scanTasks, summarize } from './task';
6
7
  import { CrewExecutor } from './config';
7
8
 
@@ -12,6 +13,9 @@ interface ProjectOpts {
12
13
  executor?: string;
13
14
  testCommand?: string;
14
15
  noWrite?: boolean;
16
+ from?: string;
17
+ keepMissing?: boolean;
18
+ archiveMissing?: boolean;
15
19
  }
16
20
 
17
21
  export function projectAdd(name: string, opts: ProjectOpts): void {
@@ -66,7 +70,7 @@ export function projectStatus(name: string, opts: ProjectOpts): void {
66
70
  print.error(`unknown project: ${name}`);
67
71
  process.exit(1);
68
72
  }
69
- const tasks = scanTasks(crew).filter(t => t.scope !== 'inbox' && (t.scope === `project:${name}` || t.fields.project === name));
73
+ const tasks = scanTasks(crew).filter(t => t.scope !== 'inbox' && (t.scope === `project:${name}` || t.scope.startsWith(`project:${name}:`) || t.fields.project === name));
70
74
  const s = summarize(tasks);
71
75
  print.info(`project: ${name} (${project.owner})`);
72
76
  // eslint-disable-next-line no-console
@@ -77,8 +81,90 @@ export function projectStatus(name: string, opts: ProjectOpts): void {
77
81
  console.log(`tasks: ${s.done}/${s.total} done, ${s.open} open, ${s.blocked} blocked`);
78
82
  }
79
83
 
84
+ export function projectSync(opts: ProjectOpts): void {
85
+ const cfg = loadCrewConfig();
86
+ const crew = requireCrew(opts.crew);
87
+ const crewIndex = cfg.crews.findIndex(c => c.name === crew.name);
88
+ const entry = normalizeCrew(cfg.crews[crewIndex]);
89
+ const scanRoot = path.resolve(entry.vault, opts.from || 'projects');
90
+ if (!fs.existsSync(scanRoot)) {
91
+ print.error(`projects directory not found: ${shortPath(scanRoot)}`);
92
+ process.exit(1);
93
+ }
94
+
95
+ const found = fs.readdirSync(scanRoot, { withFileTypes: true })
96
+ .filter(d => d.isDirectory() && !d.name.startsWith('.') && !d.name.startsWith('_'))
97
+ .map(d => ({ name: d.name, path: path.join(scanRoot, d.name) }))
98
+ .sort((a, b) => a.name.localeCompare(b.name));
99
+
100
+ const existing = new Map((entry.projects || []).map(p => [p.name, p]));
101
+ const seen = new Set(found.map(p => p.name));
102
+ const next: CrewProject[] = [];
103
+ const added: string[] = [];
104
+ const updated: string[] = [];
105
+ const removed: CrewProject[] = [];
106
+
107
+ for (const item of found) {
108
+ const old = existing.get(item.name);
109
+ if (old) {
110
+ const project = { ...old, path: item.path };
111
+ next.push(project);
112
+ updated.push(item.name);
113
+ } else {
114
+ const project: CrewProject = {
115
+ name: item.name,
116
+ path: item.path,
117
+ owner: `maintainer:${item.name}`,
118
+ defaultExecutor: parseExecutor(opts.executor || entry.defaultExecutor || 'claude'),
119
+ canWriteCode: !opts.noWrite,
120
+ defaultTestCommand: opts.testCommand,
121
+ };
122
+ next.push(project);
123
+ added.push(item.name);
124
+ }
125
+ }
126
+
127
+ for (const project of entry.projects || []) {
128
+ if (seen.has(project.name)) continue;
129
+ if (!isInside(project.path, scanRoot)) {
130
+ next.push(project);
131
+ continue;
132
+ }
133
+ if (opts.keepMissing) next.push(project);
134
+ else removed.push(project);
135
+ }
136
+
137
+ entry.projects = next;
138
+ cfg.crews[crewIndex] = entry;
139
+ saveCrewConfig(cfg);
140
+
141
+ for (const project of next) ensureProject(entry, project);
142
+ if (!opts.keepMissing && opts.archiveMissing !== false) {
143
+ for (const project of removed) archiveProjectFolder(entry, project.name);
144
+ }
145
+
146
+ print.succeed(`project registry synced from ${shortPath(scanRoot)}`);
147
+ print.info(`added: ${added.length ? added.join(', ') : '-'}`);
148
+ print.info(`updated: ${updated.length ? updated.join(', ') : '-'}`);
149
+ print.info(`removed: ${removed.length ? removed.map(p => p.name).join(', ') : '-'}`);
150
+ }
151
+
80
152
  function parseExecutor(value: string): CrewExecutor {
81
153
  if (value === 'claude' || value === 'codex' || value === 'pi') return value;
82
154
  print.error(`unknown executor: ${value}. Expected claude, codex, or pi.`);
83
155
  process.exit(1);
84
156
  }
157
+
158
+ function isInside(child: string, parent: string): boolean {
159
+ const rel = path.relative(path.resolve(parent), path.resolve(child));
160
+ return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
161
+ }
162
+
163
+ function archiveProjectFolder(crew: ReturnType<typeof normalizeCrew>, name: string): void {
164
+ const from = rootPath(crew, path.join('Projects', name));
165
+ if (!fs.existsSync(from)) return;
166
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+$/, '').replace('T', '-');
167
+ const to = rootPath(crew, path.join('Projects', '_Archived', `${name}-${stamp}`));
168
+ fs.mkdirSync(path.dirname(to), { recursive: true });
169
+ fs.renameSync(from, to);
170
+ }
package/lib/crew/role.ts CHANGED
@@ -17,12 +17,12 @@ export interface CrewRoleDefinition {
17
17
  export const BUILTIN_ROLE_NAMES = ['lead', 'designer', 'pm', 'coder', 'tester', 'researcher'];
18
18
 
19
19
  const BUILTIN_ROLES: CrewRoleDefinition[] = [
20
- { name: 'lead', title: 'Lead', folder: 'Lead', description: 'Primary user-facing coordinator.', builtIn: true },
21
- { name: 'designer', title: 'Designer', folder: 'Designer', description: 'Interaction, UX, information architecture, and visual review.', builtIn: true },
22
- { name: 'pm', title: 'PM', folder: 'PM', description: 'PRD generation, requirements review, scope, and acceptance criteria.', builtIn: true },
23
- { name: 'coder', title: 'Coder', folder: 'Coder', description: 'Implementation work when no project owner is more specific.', builtIn: true },
24
- { name: 'tester', title: 'Tester', folder: 'Tester', description: 'Verification, test planning, and PRD-scoped E2E checks.', builtIn: true },
25
- { name: 'researcher', title: 'Researcher', folder: 'Researcher', description: 'Source-backed research and durable reports.', builtIn: true },
20
+ { name: 'lead', title: 'Lead', folder: 'Lead', description: 'Coordinates goals, project owners, roles, Inbox decisions, and dashboard status.', builtIn: true },
21
+ { name: 'designer', title: 'Designer', folder: 'Designer', description: 'Reviews user flows, interaction details, information architecture, and visual fit.', builtIn: true },
22
+ { name: 'pm', title: 'PM', folder: 'PM', description: 'Turns goals into PRDs, scope boundaries, acceptance criteria, and open questions.', builtIn: true },
23
+ { name: 'coder', title: 'Coder', folder: 'Coder', description: 'Implements project-scoped code tasks assigned by a Project Owner or Lead.', builtIn: true },
24
+ { name: 'tester', title: 'Tester', folder: 'Tester', description: 'Plans and runs verification, defaulting frontend work to PRD-scoped Playwright E2E.', builtIn: true },
25
+ { name: 'researcher', title: 'Researcher', folder: 'Researcher', description: 'Produces source-backed research reports and keeps a lightweight research index.', builtIn: true },
26
26
  ];
27
27
 
28
28
  export function normalizeRoleName(input: string): string {
package/lib/crew/task.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { CrewEntry } from './config';
4
- import { rootPath } from './vault';
5
- import { roleDefinitionsForCrew } from './role';
4
+ import { projectAgentFolder, rootPath } from './vault';
5
+ import { normalizeRoleName, roleDefinitionsForCrew } from './role';
6
6
 
7
7
  export interface CrewTask {
8
8
  id?: string;
@@ -76,17 +76,66 @@ function parseTaskLine(line: string): Omit<CrewTask, 'file' | 'line' | 'scope'>
76
76
  function taskFiles(crew: CrewEntry): { file: string; scope: string }[] {
77
77
  const files = [
78
78
  { file: rootPath(crew, 'Inbox.md'), scope: 'inbox' },
79
- ...roleDefinitionsForCrew(crew).map(role => ({
80
- file: rootPath(crew, path.join(role.folder, 'Tasks.md')),
81
- scope: `role:${role.name}`,
82
- })),
83
79
  ];
80
+ const roles = roleDefinitionsForCrew(crew);
81
+
82
+ // Backward compatibility: old crew roots may still contain global role
83
+ // task files. New init writes role descriptions only and scopes concrete
84
+ // work under Projects/<project>/Agents/<role>/Tasks.md.
85
+ for (const role of roles) {
86
+ const legacy = rootPath(crew, path.join(role.folder, 'Tasks.md'));
87
+ if (fs.existsSync(legacy)) files.push({ file: legacy, scope: `legacy-role:${role.name}` });
88
+ }
89
+
84
90
  const projectsDir = rootPath(crew, 'Projects');
85
- if (fs.existsSync(projectsDir)) {
86
- for (const name of fs.readdirSync(projectsDir)) {
87
- const file = path.join(projectsDir, name, 'Tasks.md');
88
- if (fs.existsSync(file)) files.push({ file, scope: `project:${name}` });
91
+ const projectNames = configuredProjectNames(crew, projectsDir);
92
+ for (const name of projectNames) {
93
+ const base = path.join(projectsDir, name);
94
+ const file = path.join(base, 'Tasks.md');
95
+ if (fs.existsSync(file)) files.push({ file, scope: `project:${name}` });
96
+ files.push(...tasklistFiles(path.join(base, 'Tasklists', 'active'), `project:${name}:tasklist`));
97
+
98
+ const seenRoleFolders = new Set<string>();
99
+ for (const role of roles) {
100
+ const folder = projectAgentFolder(role);
101
+ seenRoleFolders.add(folder);
102
+ const roleFile = path.join(base, 'Agents', folder, 'Tasks.md');
103
+ if (fs.existsSync(roleFile)) files.push({ file: roleFile, scope: `project:${name}:role:${role.name}` });
104
+ files.push(...tasklistFiles(path.join(base, 'Agents', folder, 'Tasklists', 'active'), `project:${name}:role:${role.name}:tasklist`));
105
+ }
106
+
107
+ const agentsDir = path.join(base, 'Agents');
108
+ if (fs.existsSync(agentsDir)) {
109
+ for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
110
+ if (!entry.isDirectory() || seenRoleFolders.has(entry.name)) continue;
111
+ const roleFile = path.join(agentsDir, entry.name, 'Tasks.md');
112
+ const roleName = normalizeRoleName(entry.name);
113
+ if (fs.existsSync(roleFile)) files.push({ file: roleFile, scope: `project:${name}:role:${roleName}` });
114
+ files.push(...tasklistFiles(path.join(agentsDir, entry.name, 'Tasklists', 'active'), `project:${name}:role:${roleName}:tasklist`));
115
+ }
89
116
  }
90
117
  }
91
118
  return files.filter(item => fs.existsSync(item.file));
92
119
  }
120
+
121
+ function configuredProjectNames(crew: CrewEntry, projectsDir: string): string[] {
122
+ if (crew.projects && crew.projects.length > 0) return crew.projects.map(p => p.name);
123
+ if (!fs.existsSync(projectsDir)) return [];
124
+ return fs.readdirSync(projectsDir, { withFileTypes: true })
125
+ .filter(entry => entry.isDirectory() && !entry.name.startsWith('_') && !entry.name.startsWith('.'))
126
+ .map(entry => entry.name);
127
+ }
128
+
129
+ function tasklistFiles(dir: string, scope: string): { file: string; scope: string }[] {
130
+ if (!fs.existsSync(dir)) return [];
131
+ const files: { file: string; scope: string }[] = [];
132
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
133
+ const full = path.join(dir, entry.name);
134
+ if (entry.isDirectory()) {
135
+ files.push(...tasklistFiles(full, scope));
136
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
137
+ files.push({ file: full, scope });
138
+ }
139
+ }
140
+ return files.sort((a, b) => a.file.localeCompare(b.file));
141
+ }
package/lib/crew/vault.ts CHANGED
@@ -46,9 +46,12 @@ export function ensureProject(crew: CrewEntry, project: CrewProject): void {
46
46
  ensureDir(base);
47
47
  writeProjectOwnerFile(path.join(base, 'Owner.md'), project);
48
48
  writeIfMissing(path.join(base, 'Context.md'), `# ${project.name} Context\n\n`);
49
- writeIfMissing(path.join(base, 'Tasks.md'), `# ${project.name} Tasks\n\n`);
49
+ writeIfMissing(path.join(base, 'Tasks.md'), renderProjectTasksFile(project));
50
+ ensureTasklists(base);
50
51
  writeIfMissing(path.join(base, 'Decisions.md'), `# ${project.name} Decisions\n\n`);
51
52
  writeIfMissing(path.join(base, 'Test-Plan.md'), `# ${project.name} Test Plan\n\n`);
53
+ ensureDir(path.join(base, 'Agents'));
54
+ for (const role of roleDefinitionsForCrew(crew)) ensureProjectAgentTasks(base, project, role);
52
55
  ensureDir(path.join(base, 'PRDs'));
53
56
  writeIfMissing(path.join(base, 'PRDs', '.gitkeep'), '');
54
57
  ensureDir(path.join(base, 'Reports'));
@@ -77,17 +80,11 @@ function ensureRole(crew: CrewEntry, role: CrewRoleDefinition): void {
77
80
  const folder = role.folder;
78
81
  const base = rootPath(crew, folder);
79
82
  ensureDir(base);
80
- writeIfMissing(path.join(base, 'Tasks.md'), `# ${folder} Tasks\n\n`);
81
- writeIfMissing(path.join(base, 'Notes.md'), `# ${folder} Notes\n\n`);
83
+ writeIfMissing(path.join(base, 'Role.md'), renderRoleFile(role));
82
84
  if (role.name === 'lead') {
83
85
  ensureDir(path.join(base, 'Reports'));
84
86
  writeIfMissing(path.join(base, 'Reports', '.gitkeep'), '');
85
87
  }
86
- if (role.name === 'pm') {
87
- ensureDir(path.join(base, 'PRDs'));
88
- writeIfMissing(path.join(base, 'PRDs', '.gitkeep'), '');
89
- writeIfMissing(path.join(base, 'Reviews.md'), '# PM Reviews\n\n');
90
- }
91
88
  if (role.name === 'researcher') {
92
89
  ensureDir(path.join(base, 'Reports'));
93
90
  writeIfMissing(path.join(base, 'Reports', '.gitkeep'), '');
@@ -96,7 +93,6 @@ function ensureRole(crew: CrewEntry, role: CrewRoleDefinition): void {
96
93
  if (!role.builtIn) {
97
94
  ensureDir(path.join(base, 'Reports'));
98
95
  writeIfMissing(path.join(base, 'Reports', '.gitkeep'), '');
99
- writeIfMissing(path.join(base, 'Role.md'), renderRoleFile(role));
100
96
  }
101
97
  }
102
98
 
@@ -146,16 +142,68 @@ function renderRoleFile(role: CrewRoleDefinition): string {
146
142
  return [
147
143
  `# ${role.title}`,
148
144
  '',
149
- `Role: ${role.name}`,
150
- `Agent: ${role.agent || '-'}`,
151
- `Executor: ${role.defaultExecutor || '-'}`,
152
- `Prompt: ${role.promptPath || '-'}`,
145
+ `Role: \`${role.name}\``,
146
+ `Agent: \`${role.agent || '-'}\``,
147
+ `Default executor: \`${role.defaultExecutor || '-'}\``,
148
+ `Prompt: \`${role.promptPath || '-'}\``,
149
+ '',
150
+ '## Purpose',
153
151
  '',
154
152
  role.description || '',
155
153
  '',
154
+ '## Task Location',
155
+ '',
156
+ 'This role is reusable across projects. Do not keep normal project work here.',
157
+ 'Assign concrete work under `Projects/<project>/Agents/<role>/Tasks.md`, or under `Projects/<project>/Tasks.md` for project-owner work.',
158
+ '',
159
+ 'Users may edit this file to tune the role.',
160
+ '',
156
161
  ].join('\n');
157
162
  }
158
163
 
164
+ function ensureProjectAgentTasks(base: string, project: CrewProject, role: CrewRoleDefinition): void {
165
+ const dir = path.join(base, 'Agents', projectAgentFolder(role));
166
+ ensureDir(dir);
167
+ writeIfMissing(path.join(dir, 'Tasks.md'), renderProjectAgentTasksFile(project, role));
168
+ ensureTasklists(dir);
169
+ }
170
+
171
+ export function projectAgentFolder(role: CrewRoleDefinition): string {
172
+ if (role.builtIn && !role.folder.includes(path.sep)) return role.folder;
173
+ return role.name;
174
+ }
175
+
176
+ function renderProjectTasksFile(project: CrewProject): string {
177
+ return [
178
+ `# ${project.name} Project Tasks`,
179
+ '',
180
+ 'Keep this file short: current project-owner tasks and cross-role coordination only.',
181
+ 'For larger plans, split current work into `Tasklists/active/<feature-or-iteration>.md`.',
182
+ 'Move completed or stale batches to `Tasklists/archive/YYYY-MM.md`; archive files are not scanned for the active dashboard.',
183
+ 'Role-specific current work belongs in `Agents/<role>/Tasks.md` or `Agents/<role>/Tasklists/active/*.md`.',
184
+ '',
185
+ ].join('\n');
186
+ }
187
+
188
+ function renderProjectAgentTasksFile(project: CrewProject, role: CrewRoleDefinition): string {
189
+ return [
190
+ `# ${project.name} ${role.title} Tasks`,
191
+ '',
192
+ `Use this file for the short current ${role.title} queue scoped to \`${project.name}\`.`,
193
+ 'For larger batches, split current work into `Tasklists/active/<feature-or-iteration>.md`.',
194
+ 'Move completed or stale batches to `Tasklists/archive/YYYY-MM.md`; archive files are not scanned for the active dashboard.',
195
+ `Default inline fields: [project:: ${project.name}] [role:: ${role.name}] [owner:: ${project.owner || `maintainer:${project.name}`}] [status:: pending]`,
196
+ '',
197
+ ].join('\n');
198
+ }
199
+
200
+ function ensureTasklists(base: string): void {
201
+ ensureDir(path.join(base, 'Tasklists', 'active'));
202
+ writeIfMissing(path.join(base, 'Tasklists', 'active', '.gitkeep'), '');
203
+ ensureDir(path.join(base, 'Tasklists', 'archive'));
204
+ writeIfMissing(path.join(base, 'Tasklists', 'archive', '.gitkeep'), '');
205
+ }
206
+
159
207
  function crewPathForHome(): string {
160
208
  return process.env.RIG_HOME || path.join(process.env.HOME || '', '.rig');
161
209
  }
@@ -204,8 +252,10 @@ function renderVaultAgentInstructions(crew: CrewEntry): string {
204
252
  `- Dashboard: \`${root}/Team-Dashboard.md\``,
205
253
  `- Inbox: \`${root}/Inbox.md\``,
206
254
  `- Role registry: \`${root}/Shared/Roles.md\``,
255
+ `- Reusable role descriptions: \`${root}/<role>/Role.md\` and \`${root}/Roles/<custom-role>/Role.md\``,
207
256
  `- Project owner memory: \`${root}/Projects/<project>/\``,
208
- `- Custom role workspaces: \`${root}/Roles/<role>/\``,
257
+ `- Project-scoped agent tasks: \`${root}/Projects/<project>/Agents/<role>/Tasks.md\``,
258
+ `- 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.`,
209
259
  '- Vault-local scratch projects belong under `tmp/<project>/`.',
210
260
  '- User-level rules, test accounts, custom roles, and research output policy live under `~/.rig/`.',
211
261
  '- Coordinate through Vault files; do not start or assume a separate multi-agent runtime inside a project repo.',
@@ -217,10 +267,10 @@ function renderVaultAgentInstructions(crew: CrewEntry): string {
217
267
  '0. Do not treat `rig crew` 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.',
218
268
  '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 Crew Lead first.',
219
269
  `2. Preferred handoff: run \`rig crew "<user request>"\`, then read \`${root}/Team-Dashboard.md\`, \`${root}/Inbox.md\`, and \`${root}/Shared/Roles.md\`.`,
220
- `3. Maintain status awareness before and after work by checking \`${root}/Team-Dashboard.md\`, \`${root}/Inbox.md\`, \`${root}/Shared/Roles.md\`, role \`Tasks.md\`, and project \`Tasks.md\` files.`,
221
- `4. If the CLI is unavailable, append the request to \`${root}/Current-Goal.md\` and create/update a Lead task in \`${root}/Lead/Tasks.md\`; then refresh the dashboard when possible.`,
270
+ `3. Maintain status awareness before and after work by checking \`${root}/Team-Dashboard.md\`, \`${root}/Inbox.md\`, \`${root}/Shared/Roles.md\`, project \`Tasks.md\`, project agent \`Agents/<role>/Tasks.md\`, and active tasklists.`,
271
+ `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.`,
222
272
  '5. Treat Crew Lead as the default orchestrator 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.',
223
- '6. Lead 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 `Tasks.md`.',
273
+ '6. Lead 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`.',
224
274
  `7. Worker results must be written back to the relevant role/project files under \`${root}/\`; user-facing questions go to \`${root}/Inbox.md\` for Lead to surface.`,
225
275
  '',
226
276
  AGENT_RULES_END,
@@ -109,7 +109,11 @@ function composeEntry(vaultPath: string, vault: VaultConfig): WikiEntry {
109
109
  name: vault.name || path.basename(vaultPath),
110
110
  path: vaultPath,
111
111
  root,
112
- include: vault.include ?? ['**/*.md'],
112
+ // Default: walk everything visible. Binary extensions, hidden dirs,
113
+ // and .gitignored content are filtered at walk time by scan.ts /
114
+ // surveyed by `rig wiki survey`. include/exclude in vault config
115
+ // remain available as escape hatches for power users.
116
+ include: vault.include ?? ['**/*'],
113
117
  exclude: vault.exclude ?? [],
114
118
  schedule: vault.schedule ?? DEFAULT_SCHEDULE,
115
119
  ingestRules: vault.ingestRules ?? [{ match: 'raw/**/*.md', mode: 'auto-on-new' }],
package/lib/wiki/db.ts CHANGED
@@ -69,3 +69,32 @@ export function getLastRun(wiki: string, op: string): { ts: number; exit_code: n
69
69
  .prepare('SELECT ts, exit_code FROM last_run WHERE wiki = ? AND op = ?')
70
70
  .get(wiki, op) as { ts: number; exit_code: number } | undefined;
71
71
  }
72
+
73
+ /**
74
+ * Upsert one source-sha row. Path is the **root-relative** path (same key
75
+ * `scan` uses), so subsequent `scan` runs can detect drift via the same map.
76
+ */
77
+ export function upsertSourceSha(wiki: string, relPath: string, sha: string, mtimeMs: number): void {
78
+ getDb().prepare(
79
+ `INSERT INTO source_sha (wiki, path, sha, mtime) VALUES (?, ?, ?, ?)
80
+ ON CONFLICT(wiki, path) DO UPDATE SET sha = excluded.sha, mtime = excluded.mtime`
81
+ ).run(wiki, relPath, sha, Math.floor(mtimeMs));
82
+ }
83
+
84
+ /** Bulk-upsert variant for `scan --baseline`. Uses a single transaction. */
85
+ export function upsertSourceShasBulk(wiki: string, rows: { path: string; sha: string; mtimeMs: number }[]): void {
86
+ if (rows.length === 0) return;
87
+ const db = getDb();
88
+ const stmt = db.prepare(
89
+ `INSERT INTO source_sha (wiki, path, sha, mtime) VALUES (?, ?, ?, ?)
90
+ ON CONFLICT(wiki, path) DO UPDATE SET sha = excluded.sha, mtime = excluded.mtime`
91
+ );
92
+ const tx = db.transaction((items: { path: string; sha: string; mtimeMs: number }[]) => {
93
+ for (const r of items) stmt.run(wiki, r.path, r.sha, Math.floor(r.mtimeMs));
94
+ });
95
+ tx(rows);
96
+ }
97
+
98
+ export function deleteSourceSha(wiki: string, relPath: string): void {
99
+ getDb().prepare('DELETE FROM source_sha WHERE wiki = ? AND path = ?').run(wiki, relPath);
100
+ }
@@ -0,0 +1,52 @@
1
+ // Default file-type filters shared by `scan` and `survey`. Anything in
2
+ // BINARY_EXTENSIONS is excluded BEFORE we even consider asking the agent
3
+ // about it — these are categorically not wiki sources.
4
+ //
5
+ // The wiki's own schema.md "Ingestion policy" section drives further
6
+ // (LLM-applied) filtering inside `rig wiki survey`. This file is just
7
+ // the hard floor.
8
+
9
+ import path from 'path';
10
+
11
+ // Extensions matched by simple basename suffix. Lowercase, leading dot.
12
+ const BINARY_EXTENSIONS: ReadonlySet<string> = new Set([
13
+ // archives
14
+ '.zip', '.tar', '.tgz', '.gz', '.bz2', '.xz', '.7z', '.rar', '.dmg', '.iso',
15
+ // binaries / native modules
16
+ '.exe', '.dll', '.so', '.dylib', '.bin', '.o', '.a', '.lib',
17
+ '.class', '.jar', '.pyc', '.pyo', '.node', '.wasm',
18
+ // audio / video
19
+ '.mp4', '.mov', '.mkv', '.avi', '.webm', '.wmv', '.flv',
20
+ '.mp3', '.wav', '.flac', '.aac', '.ogg', '.m4a', '.opus',
21
+ // design / proprietary
22
+ '.psd', '.ai', '.fig', '.sketch', '.fla', '.indd', '.xd',
23
+ // model weights / embeddings
24
+ '.gguf', '.safetensors', '.pt', '.pth', '.onnx', '.h5', '.pkl', '.npz', '.tflite',
25
+ // build / lock / source-map artifacts (handled by .min.js / .lock suffix too)
26
+ '.map', '.tsbuildinfo',
27
+ // misc
28
+ '.ds_store', '.pyd', '.swp', '.swo',
29
+ ]);
30
+
31
+ // Filename patterns matched by exact basename or known suffix combos.
32
+ function isBinaryByName(basename: string): boolean {
33
+ const lower = basename.toLowerCase();
34
+ // multi-segment archives
35
+ if (lower.endsWith('.tar.gz') || lower.endsWith('.tar.bz2') || lower.endsWith('.tar.xz')) return true;
36
+ // minified js / source maps
37
+ if (lower.endsWith('.min.js') || lower.endsWith('.min.css') || lower.endsWith('.bundle.js')) return true;
38
+ // lockfiles
39
+ if (lower === 'yarn.lock' || lower === 'package-lock.json' || lower === 'pnpm-lock.yaml' || lower === 'cargo.lock' || lower === 'composer.lock' || lower === 'gemfile.lock') return true;
40
+ if (lower.endsWith('.lock')) return true;
41
+ return false;
42
+ }
43
+
44
+ /**
45
+ * True if the file should be skipped as a wiki source on extension /
46
+ * filename grounds alone. Cheap, deterministic, no I/O.
47
+ */
48
+ export function isBinaryExtension(filePath: string): boolean {
49
+ const ext = path.extname(filePath).toLowerCase();
50
+ if (ext && BINARY_EXTENSIONS.has(ext)) return true;
51
+ return isBinaryByName(path.basename(filePath));
52
+ }
package/lib/wiki/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import wikiInit from './init';
2
2
  import wikiScan from './scan';
3
+ import wikiSurvey from './survey';
3
4
  import wikiFetch from './fetch';
4
5
  import wikiIngest from './ingest';
5
6
  import wikiQuery from './query';
@@ -25,9 +26,18 @@ export function registerWikiCommands(program: any): void {
25
26
 
26
27
  wiki.command('scan')
27
28
  .description('compute NEW/MODIFIED/DELETED/RAW DRIFT report for the vault resolved from CWD')
29
+ .option('-b, --baseline', 'commit current shas into state.db so future scans can detect drift (no wiki content changed)')
28
30
  .option('--json', 'machine-readable output')
29
31
  .action(wikiScan);
30
32
 
33
+ wiki.command('survey')
34
+ .description('triage candidate files via schema.md "Ingestion policy" — agent decides ingest/skip/unclear per file')
35
+ .option('-a, --apply', 'auto-ingest every "ingest" decision (sequential)')
36
+ .option('-l, --limit <n>', `cap candidates passed to the agent (default 500)`, (v) => parseInt(v, 10))
37
+ .option('--no-agent', 'skip agent classification; accept every non-binary candidate (local rules only)')
38
+ .option('--json', 'machine-readable output')
39
+ .action(wikiSurvey);
40
+
31
41
  wiki.command('fetch <url>')
32
42
  .description('verbatim download URL into raw/YYYY-MM-DD-<slug>.md')
33
43
  .option('--slug <slug>', 'override the auto-derived slug')