rigjs 4.0.18 → 4.1.0
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/.claude/skills/rig-cicd/SKILL.md +288 -0
- package/.claude/skills/rig-package/SKILL.md +162 -0
- package/RIG_CICD_SKILL.md +288 -0
- package/RIG_CREW_SKILL.md +50 -50
- package/RIG_PACKAGE_SKILL.md +162 -0
- package/built/index.js +346 -259
- package/lib/classes/cicd/CICD.ts +17 -0
- package/lib/classes/cicd/Deploy/ESA.ts +117 -0
- package/lib/crew/ask.ts +3 -3
- package/lib/crew/board.ts +14 -14
- package/lib/crew/config.ts +2 -2
- package/lib/crew/dispatchCommand.ts +58 -0
- package/lib/crew/doctor.ts +1 -1
- package/lib/crew/engine.test.ts +73 -0
- package/lib/crew/engine.ts +103 -0
- package/lib/crew/index.ts +48 -27
- package/lib/crew/init.ts +3 -3
- package/lib/crew/{inbox.ts → pendingQuestions.ts} +6 -7
- package/lib/crew/project.ts +1 -1
- package/lib/crew/role.ts +3 -3
- package/lib/crew/runtime.test.ts +160 -0
- package/lib/crew/runtime.ts +192 -0
- package/lib/crew/status.ts +4 -4
- package/lib/crew/stub.ts +2 -2
- package/lib/crew/task.ts +3 -3
- package/lib/crew/vault.ts +14 -14
- package/lib/init/index.ts +16 -9
- package/lib/publish/index.ts +78 -1
- package/lib/wiki/lint.ts +23 -1
- package/package.json +11 -3
- package/scripts/sync-skill.mjs +2 -0
- package/skills.md +5 -1
- package/lib/utils/redact.test.ts +0 -43
package/lib/crew/init.ts
CHANGED
|
@@ -34,7 +34,7 @@ export default function crewInit(opts: InitOpts): void {
|
|
|
34
34
|
root,
|
|
35
35
|
defaultExecutor: existing?.defaultExecutor || 'claude',
|
|
36
36
|
mode: 'leader-first',
|
|
37
|
-
dashboard: path.join(root, '
|
|
37
|
+
dashboard: path.join(root, 'Dashboard.md'),
|
|
38
38
|
state: existing?.state || { backend: 'json' },
|
|
39
39
|
roles: existing?.roles || DEFAULT_ROLES,
|
|
40
40
|
projects: existing?.projects || [],
|
|
@@ -47,7 +47,7 @@ export default function crewInit(opts: InitOpts): void {
|
|
|
47
47
|
writeUserRulesIfMissing();
|
|
48
48
|
ensureCrewVault(entry);
|
|
49
49
|
print.succeed(`crew "${name}" initialized at ${shortPath(vault)}`);
|
|
50
|
-
print.info(`agent next: use \`rig
|
|
50
|
+
print.info(`agent next: use \`rig orchestrate "<user request>"\` or update ${path.join(root, 'Current-Goal.md')} when coordinating this Vault`);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
function isUnderProjects(p: string): boolean {
|
|
@@ -89,7 +89,7 @@ function writeUserRulesIfMissing(): void {
|
|
|
89
89
|
- Default research report directory: <crew-root>/Researcher/Reports
|
|
90
90
|
- Resolve relative paths from the current crew Vault root.
|
|
91
91
|
- If the user requests an explicit output directory, use that directory unless it is inside a project submodule or contains secrets.
|
|
92
|
-
- If neither the user request nor this section gives a clear destination, ask
|
|
92
|
+
- If neither the user request nor this section gives a clear destination, ask the Orchestrator to create a Pending-Questions entry instead of guessing.
|
|
93
93
|
|
|
94
94
|
| Scope | Directory | Notes |
|
|
95
95
|
|---|---|---|
|
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
import print from '../print';
|
|
2
2
|
import { requireCrew } from './config';
|
|
3
|
-
import {
|
|
3
|
+
import { openPendingQuestions } from './task';
|
|
4
4
|
|
|
5
|
-
interface
|
|
5
|
+
interface PendingQuestionsOpts { crew?: string; json?: boolean; }
|
|
6
6
|
|
|
7
|
-
export default function
|
|
7
|
+
export default function crewPendingQuestions(opts: PendingQuestionsOpts): void {
|
|
8
8
|
const crew = requireCrew(opts.crew);
|
|
9
|
-
const items =
|
|
9
|
+
const items = openPendingQuestions(crew);
|
|
10
10
|
if (opts.json) {
|
|
11
11
|
// eslint-disable-next-line no-console
|
|
12
12
|
console.log(JSON.stringify({ ok: true, data: items }, null, 2));
|
|
13
13
|
return;
|
|
14
14
|
}
|
|
15
15
|
if (items.length === 0) {
|
|
16
|
-
print.info('
|
|
16
|
+
print.info('no open pending questions.');
|
|
17
17
|
return;
|
|
18
18
|
}
|
|
19
|
-
print.info(`open
|
|
19
|
+
print.info(`open pending questions: ${items.length}`);
|
|
20
20
|
for (const t of items) {
|
|
21
21
|
// eslint-disable-next-line no-console
|
|
22
22
|
console.log(`- ${t.id || 'NO-ID'} ${clean(t.text)} (${t.fields.priority || 'no priority'})`);
|
|
@@ -26,4 +26,3 @@ export default function crewInbox(opts: InboxOpts): void {
|
|
|
26
26
|
function clean(text: string): string {
|
|
27
27
|
return text.replace(/\[[A-Za-z0-9_-]+::\s*[^\]]+\]/g, '').trim();
|
|
28
28
|
}
|
|
29
|
-
|
package/lib/crew/project.ts
CHANGED
|
@@ -70,7 +70,7 @@ export function projectStatus(name: string, opts: ProjectOpts): void {
|
|
|
70
70
|
print.error(`unknown project: ${name}`);
|
|
71
71
|
process.exit(1);
|
|
72
72
|
}
|
|
73
|
-
const tasks = scanTasks(crew).filter(t => t.scope !== '
|
|
73
|
+
const tasks = scanTasks(crew).filter(t => t.scope !== 'pending' && (t.scope === `project:${name}` || t.scope.startsWith(`project:${name}:`) || t.fields.project === name));
|
|
74
74
|
const s = summarize(tasks);
|
|
75
75
|
print.info(`project: ${name} (${project.owner})`);
|
|
76
76
|
// eslint-disable-next-line no-console
|
package/lib/crew/role.ts
CHANGED
|
@@ -14,13 +14,13 @@ export interface CrewRoleDefinition {
|
|
|
14
14
|
builtIn?: boolean;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export const BUILTIN_ROLE_NAMES = ['
|
|
17
|
+
export const BUILTIN_ROLE_NAMES = ['orchestrator', 'designer', 'pm', 'coder', 'tester', 'researcher'];
|
|
18
18
|
|
|
19
19
|
const BUILTIN_ROLES: CrewRoleDefinition[] = [
|
|
20
|
-
{ name: '
|
|
20
|
+
{ name: 'orchestrator', title: 'Orchestrator', folder: 'Orchestrator', description: 'Orchestrates across and within projects: selects projects, sequences cross-project dependencies, dispatches engines, manages pending questions and the dashboard. As project manager, decomposes analysis into tasks, schedules, dispatches develop/verify, tracks status, and drives merges.', builtIn: true },
|
|
21
21
|
{ name: 'designer', title: 'Designer', folder: 'Designer', description: 'Reviews user flows, interaction details, information architecture, and visual fit.', builtIn: true },
|
|
22
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
|
|
23
|
+
{ name: 'coder', title: 'Coder', folder: 'Coder', description: 'Implements project-scoped code tasks assigned by a Project Owner or the Orchestrator.', builtIn: true },
|
|
24
24
|
{ name: 'tester', title: 'Tester', folder: 'Tester', description: 'Plans and runs verification, defaulting frontend work to PRD-scoped Playwright E2E.', builtIn: true },
|
|
25
25
|
{ name: 'researcher', title: 'Researcher', folder: 'Researcher', description: 'Produces source-backed research reports and keeps a lightweight research index.', builtIn: true },
|
|
26
26
|
];
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { runCommand, buildEngineInvocation, createTaskWorktree, removeTaskWorktree, runParallel, dispatchTask } from './runtime';
|
|
5
|
+
|
|
6
|
+
const NODE = process.execPath; // verify mechanics with a harmless command, not real engines
|
|
7
|
+
|
|
8
|
+
describe('runCommand', () => {
|
|
9
|
+
it('captures stdout and exit code 0 on success', async () => {
|
|
10
|
+
const r = await runCommand(NODE, ['-e', 'process.stdout.write("hi")']);
|
|
11
|
+
expect(r.stdout).toBe('hi');
|
|
12
|
+
expect(r.code).toBe(0);
|
|
13
|
+
expect(r.timedOut).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('captures stderr and non-zero exit code on failure', async () => {
|
|
17
|
+
const r = await runCommand(NODE, ['-e', 'process.stderr.write("boom"); process.exit(3)']);
|
|
18
|
+
expect(r.stderr).toContain('boom');
|
|
19
|
+
expect(r.code).toBe(3);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('enforces a timeout (kills the process, marks timedOut)', async () => {
|
|
23
|
+
const r = await runCommand(NODE, ['-e', 'setTimeout(()=>{}, 10000)'], { timeoutMs: 200 });
|
|
24
|
+
expect(r.timedOut).toBe(true);
|
|
25
|
+
expect(r.code).toBeNull(); // killed by signal, no exit code
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('runs in the given cwd', async () => {
|
|
29
|
+
const tmp = os.tmpdir();
|
|
30
|
+
const r = await runCommand(NODE, ['-e', 'process.stdout.write(process.cwd())'], { cwd: tmp });
|
|
31
|
+
// macOS /tmp is a symlink to /private/tmp; just assert it resolved somewhere under tmp's basename
|
|
32
|
+
expect(r.stdout.length).toBeGreaterThan(0);
|
|
33
|
+
expect(r.code).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('pipes stdin input', async () => {
|
|
37
|
+
const r = await runCommand(
|
|
38
|
+
NODE,
|
|
39
|
+
['-e', 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>process.stdout.write(d.toUpperCase()))'],
|
|
40
|
+
{ input: 'hi' },
|
|
41
|
+
);
|
|
42
|
+
expect(r.stdout).toBe('HI');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('rejects when the binary does not exist', async () => {
|
|
46
|
+
await expect(runCommand('definitely-not-a-real-binary-xyz', [])).rejects.toThrow();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('caps captured output at maxOutputBytes and flags truncated', async () => {
|
|
50
|
+
const r = await runCommand(NODE, ['-e', 'process.stdout.write("x".repeat(5000))'], { maxOutputBytes: 100 });
|
|
51
|
+
expect(r.truncated).toBe(true);
|
|
52
|
+
expect(r.stdout.length).toBe(100);
|
|
53
|
+
expect(r.code).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('does not flag truncated for small output', async () => {
|
|
57
|
+
const r = await runCommand(NODE, ['-e', 'process.stdout.write("hi")']);
|
|
58
|
+
expect(r.truncated).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('closes child stdin by default so stdin-reading children get EOF (no hang)', async () => {
|
|
62
|
+
// Without closing stdin, a child that reads until "end" never fires it and hangs
|
|
63
|
+
// (this is the codex-exec hang found in the dual-engine smoke).
|
|
64
|
+
const r = await runCommand(
|
|
65
|
+
NODE,
|
|
66
|
+
['-e', 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>process.stdout.write("EOF:"+d.length))'],
|
|
67
|
+
{ timeoutMs: 5000 },
|
|
68
|
+
);
|
|
69
|
+
expect(r.stdout).toBe('EOF:0');
|
|
70
|
+
expect(r.timedOut).toBe(false);
|
|
71
|
+
expect(r.code).toBe(0);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('buildEngineInvocation', () => {
|
|
76
|
+
it('maps claude to headless print mode', () => {
|
|
77
|
+
expect(buildEngineInvocation('claude', 'do X')).toEqual({ cmd: 'claude', args: ['-p', 'do X'] });
|
|
78
|
+
});
|
|
79
|
+
it('maps codex to exec', () => {
|
|
80
|
+
expect(buildEngineInvocation('codex', 'do X')).toEqual({ cmd: 'codex', args: ['exec', 'do X'] });
|
|
81
|
+
});
|
|
82
|
+
it('throws for pi (not yet implemented)', () => {
|
|
83
|
+
expect(() => buildEngineInvocation('pi' as any, 'x')).toThrow(/not implemented/);
|
|
84
|
+
});
|
|
85
|
+
it('throws for unknown engine', () => {
|
|
86
|
+
expect(() => buildEngineInvocation('gpt' as any, 'x')).toThrow(/unknown engine/);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('worktree lifecycle', () => {
|
|
91
|
+
let repo: string;
|
|
92
|
+
|
|
93
|
+
beforeAll(async () => {
|
|
94
|
+
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'rig-wt-'));
|
|
95
|
+
await runCommand('git', ['init', '-q'], { cwd: repo });
|
|
96
|
+
fs.writeFileSync(path.join(repo, 'f.txt'), 'hello');
|
|
97
|
+
await runCommand('git', ['-c', 'user.email=t@t', '-c', 'user.name=t', 'add', '-A'], { cwd: repo });
|
|
98
|
+
await runCommand('git', ['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '-m', 'init'], { cwd: repo });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
afterAll(() => {
|
|
102
|
+
try { fs.rmSync(repo, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('creates and removes a task worktree on a task/<id> branch', async () => {
|
|
106
|
+
const wt = await createTaskWorktree(repo, 'demo-001');
|
|
107
|
+
expect(wt.branch).toBe('task/demo-001');
|
|
108
|
+
expect(fs.existsSync(wt.path)).toBe(true);
|
|
109
|
+
const br = await runCommand('git', ['branch', '--list', 'task/demo-001'], { cwd: repo });
|
|
110
|
+
expect(br.stdout).toContain('task/demo-001');
|
|
111
|
+
await removeTaskWorktree(repo, wt.path, { force: true });
|
|
112
|
+
expect(fs.existsSync(wt.path)).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('throws when the worktree/branch already exists', async () => {
|
|
116
|
+
const wt = await createTaskWorktree(repo, 'dup-001');
|
|
117
|
+
await expect(createTaskWorktree(repo, 'dup-001')).rejects.toThrow(/worktree add failed/);
|
|
118
|
+
await removeTaskWorktree(repo, wt.path, { force: true });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('dispatchTask runs the invocation inside the task worktree (kept on success)', async () => {
|
|
122
|
+
const inv = { cmd: process.execPath, args: ['-e', 'process.stdout.write(process.cwd())'] };
|
|
123
|
+
const { worktree, result } = await dispatchTask(repo, 'disp-001', inv);
|
|
124
|
+
expect(result.code).toBe(0);
|
|
125
|
+
// ran inside the worktree (cwd echoed); realpath to dodge macOS /var symlink
|
|
126
|
+
expect(fs.realpathSync(result.stdout.trim())).toBe(fs.realpathSync(worktree.path));
|
|
127
|
+
expect(fs.existsSync(worktree.path)).toBe(true); // kept until merge
|
|
128
|
+
await removeTaskWorktree(repo, worktree.path, { force: true });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('dispatchTask removes the worktree on spawn failure', async () => {
|
|
132
|
+
await expect(dispatchTask(repo, 'disp-fail', { cmd: 'definitely-not-a-real-binary-xyz', args: [] }))
|
|
133
|
+
.rejects.toThrow();
|
|
134
|
+
expect(fs.existsSync(path.join(repo, '.worktrees', 'task-disp-fail'))).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('runParallel', () => {
|
|
139
|
+
it('preserves order and returns all results', async () => {
|
|
140
|
+
const out = await runParallel([1, 2, 3, 4, 5], async n => n * 2, 2);
|
|
141
|
+
expect(out).toEqual([2, 4, 6, 8, 10]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('never exceeds the concurrency limit', async () => {
|
|
145
|
+
let active = 0;
|
|
146
|
+
let maxActive = 0;
|
|
147
|
+
await runParallel(
|
|
148
|
+
Array.from({ length: 8 }, (_, i) => i),
|
|
149
|
+
async () => {
|
|
150
|
+
active++;
|
|
151
|
+
maxActive = Math.max(maxActive, active);
|
|
152
|
+
await new Promise(r => setTimeout(r, 15));
|
|
153
|
+
active--;
|
|
154
|
+
},
|
|
155
|
+
3,
|
|
156
|
+
);
|
|
157
|
+
expect(maxActive).toBeLessThanOrEqual(3);
|
|
158
|
+
expect(maxActive).toBeGreaterThan(1); // actually ran concurrently
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { Engine } from './engine';
|
|
4
|
+
|
|
5
|
+
// Agent runtime — engine-agnostic process primitives (design §2.2 "agent runtime").
|
|
6
|
+
// This module is the foundational layer the dispatcher builds on: spawn a CLI
|
|
7
|
+
// engine in a task worktree, capture its output, enforce a timeout. Engine-specific
|
|
8
|
+
// behaviour (claude/codex flags) is isolated in `buildEngineInvocation` and verified
|
|
9
|
+
// against the real CLIs in the dual-engine smoke; the mechanics here are verified
|
|
10
|
+
// with harmless commands (echo / node -e / sleep) — see runtime.test.ts.
|
|
11
|
+
|
|
12
|
+
export interface RunResult {
|
|
13
|
+
stdout: string;
|
|
14
|
+
stderr: string;
|
|
15
|
+
code: number | null;
|
|
16
|
+
signal: NodeJS.Signals | null;
|
|
17
|
+
timedOut: boolean;
|
|
18
|
+
/** True when stdout or stderr hit maxOutputBytes and was truncated. */
|
|
19
|
+
truncated: boolean;
|
|
20
|
+
durationMs: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RunOptions {
|
|
24
|
+
cwd?: string;
|
|
25
|
+
/** Hard timeout in ms; <=0 or undefined disables it. On timeout: SIGTERM, then SIGKILL after a grace period. */
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
/** Sent to stdin then closed. */
|
|
28
|
+
input?: string;
|
|
29
|
+
env?: NodeJS.ProcessEnv;
|
|
30
|
+
/** Per-stream capture cap in bytes (default 10MB). Guards against an agentic engine emitting unbounded output. */
|
|
31
|
+
maxOutputBytes?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const KILL_GRACE_MS = 2000;
|
|
35
|
+
const MAX_OUTPUT_BYTES_DEFAULT = 10_000_000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Spawn a command, capture stdout/stderr, enforce an optional timeout.
|
|
39
|
+
* Resolves with a RunResult (even on non-zero exit / timeout); rejects only when
|
|
40
|
+
* the process cannot be spawned at all (e.g. binary not found).
|
|
41
|
+
*/
|
|
42
|
+
export function runCommand(cmd: string, args: string[], opts: RunOptions = {}): Promise<RunResult> {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const start = Date.now();
|
|
45
|
+
let settled = false;
|
|
46
|
+
let stdout = '';
|
|
47
|
+
let stderr = '';
|
|
48
|
+
let timedOut = false;
|
|
49
|
+
let truncated = false;
|
|
50
|
+
let killTimer: NodeJS.Timeout | undefined;
|
|
51
|
+
let graceTimer: NodeJS.Timeout | undefined;
|
|
52
|
+
const maxBytes = opts.maxOutputBytes && opts.maxOutputBytes > 0 ? opts.maxOutputBytes : MAX_OUTPUT_BYTES_DEFAULT;
|
|
53
|
+
|
|
54
|
+
const child = spawn(cmd, args, { cwd: opts.cwd, env: opts.env || process.env });
|
|
55
|
+
|
|
56
|
+
if (opts.timeoutMs && opts.timeoutMs > 0) {
|
|
57
|
+
killTimer = setTimeout(() => {
|
|
58
|
+
timedOut = true;
|
|
59
|
+
child.kill('SIGTERM');
|
|
60
|
+
graceTimer = setTimeout(() => child.kill('SIGKILL'), KILL_GRACE_MS);
|
|
61
|
+
}, opts.timeoutMs);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
child.stdout?.on('data', d => {
|
|
65
|
+
if (stdout.length >= maxBytes) { truncated = true; return; }
|
|
66
|
+
stdout += d.toString();
|
|
67
|
+
if (stdout.length > maxBytes) { stdout = stdout.slice(0, maxBytes); truncated = true; }
|
|
68
|
+
});
|
|
69
|
+
child.stderr?.on('data', d => {
|
|
70
|
+
if (stderr.length >= maxBytes) { truncated = true; return; }
|
|
71
|
+
stderr += d.toString();
|
|
72
|
+
if (stderr.length > maxBytes) { stderr = stderr.slice(0, maxBytes); truncated = true; }
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
child.on('error', err => {
|
|
76
|
+
if (settled) return;
|
|
77
|
+
settled = true;
|
|
78
|
+
if (killTimer) clearTimeout(killTimer);
|
|
79
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
80
|
+
reject(err);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
child.on('close', (code, signal) => {
|
|
84
|
+
if (settled) return;
|
|
85
|
+
settled = true;
|
|
86
|
+
if (killTimer) clearTimeout(killTimer);
|
|
87
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
88
|
+
resolve({ stdout, stderr, code, signal, timedOut, truncated, durationMs: Date.now() - start });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Always close stdin (with input if given, empty otherwise). Engines like
|
|
92
|
+
// `codex exec` block forever waiting for stdin EOF if it's left open; closing
|
|
93
|
+
// it makes them proceed with the prompt arg. (Found in the dual-engine smoke.)
|
|
94
|
+
if (opts.input != null) child.stdin?.write(opts.input);
|
|
95
|
+
child.stdin?.end();
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface Invocation { cmd: string; args: string[]; }
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Map an engine + prompt to a one-shot, non-interactive CLI invocation (design §2.2).
|
|
103
|
+
* Exact flags are verified against the real CLIs in the dual-engine smoke; keep the
|
|
104
|
+
* mapping here so the dispatcher stays engine-agnostic.
|
|
105
|
+
*/
|
|
106
|
+
export function buildEngineInvocation(engine: Engine, prompt: string): Invocation {
|
|
107
|
+
switch (engine) {
|
|
108
|
+
case 'claude': return { cmd: 'claude', args: ['-p', prompt] };
|
|
109
|
+
case 'codex': return { cmd: 'codex', args: ['exec', prompt] };
|
|
110
|
+
case 'pi': throw new Error('pi engine invocation is not implemented yet');
|
|
111
|
+
default: throw new Error(`unknown engine: ${engine as string}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --- Worktree lifecycle (design §2.2 "worktree 隔离") ---
|
|
116
|
+
// Each dispatched task runs in its own git worktree on a `task/<id>` branch, so
|
|
117
|
+
// parallel engines never collide in one working tree. Created before dispatch,
|
|
118
|
+
// removed after merge/abandon.
|
|
119
|
+
|
|
120
|
+
export interface Worktree { path: string; branch: string; }
|
|
121
|
+
|
|
122
|
+
/** `git worktree add <dir> -b task/<taskId> <base>` in `repoDir`. */
|
|
123
|
+
export async function createTaskWorktree(
|
|
124
|
+
repoDir: string,
|
|
125
|
+
taskId: string,
|
|
126
|
+
opts: { base?: string; worktreeDir?: string } = {},
|
|
127
|
+
): Promise<Worktree> {
|
|
128
|
+
const branch = `task/${taskId}`;
|
|
129
|
+
const wtDir = opts.worktreeDir || path.join(repoDir, '.worktrees', `task-${taskId}`);
|
|
130
|
+
const base = opts.base || 'HEAD';
|
|
131
|
+
const r = await runCommand('git', ['worktree', 'add', wtDir, '-b', branch, base], { cwd: repoDir });
|
|
132
|
+
if (r.code !== 0) throw new Error(`git worktree add failed (${r.code}): ${(r.stderr || r.stdout).trim()}`);
|
|
133
|
+
return { path: wtDir, branch };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `git worktree remove <path> [--force]` in `repoDir`. */
|
|
137
|
+
export async function removeTaskWorktree(
|
|
138
|
+
repoDir: string,
|
|
139
|
+
worktreePath: string,
|
|
140
|
+
opts: { force?: boolean } = {},
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
const args = ['worktree', 'remove', worktreePath];
|
|
143
|
+
if (opts.force) args.push('--force');
|
|
144
|
+
const r = await runCommand('git', args, { cwd: repoDir });
|
|
145
|
+
if (r.code !== 0) throw new Error(`git worktree remove failed (${r.code}): ${(r.stderr || r.stdout).trim()}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --- Dispatch + parallel scheduling (design §2.2 "并行调度") ---
|
|
149
|
+
|
|
150
|
+
/** Run `worker` over `items` with at most `limit` concurrent; results keep input order. */
|
|
151
|
+
export async function runParallel<T, R>(
|
|
152
|
+
items: T[],
|
|
153
|
+
worker: (item: T, index: number) => Promise<R>,
|
|
154
|
+
limit = 4,
|
|
155
|
+
): Promise<R[]> {
|
|
156
|
+
const results: R[] = new Array(items.length);
|
|
157
|
+
let next = 0;
|
|
158
|
+
const lim = Math.max(1, limit);
|
|
159
|
+
async function runner(): Promise<void> {
|
|
160
|
+
for (;;) {
|
|
161
|
+
const i = next++;
|
|
162
|
+
if (i >= items.length) return;
|
|
163
|
+
results[i] = await worker(items[i], i);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
await Promise.all(Array.from({ length: Math.min(lim, items.length) }, () => runner()));
|
|
167
|
+
return results;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface DispatchResult { worktree: Worktree; result: RunResult; }
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Create a task worktree, run `invocation` inside it, return its worktree + RunResult.
|
|
174
|
+
* On success the worktree is KEPT (the work lives there until merge/abandon); on spawn
|
|
175
|
+
* failure it is removed so no orphan worktree is left. The engine→invocation mapping is
|
|
176
|
+
* the caller's job (`buildEngineInvocation`), keeping dispatch engine-agnostic.
|
|
177
|
+
*/
|
|
178
|
+
export async function dispatchTask(
|
|
179
|
+
repoDir: string,
|
|
180
|
+
taskId: string,
|
|
181
|
+
invocation: Invocation,
|
|
182
|
+
opts: { base?: string; timeoutMs?: number } = {},
|
|
183
|
+
): Promise<DispatchResult> {
|
|
184
|
+
const worktree = await createTaskWorktree(repoDir, taskId, { base: opts.base });
|
|
185
|
+
try {
|
|
186
|
+
const result = await runCommand(invocation.cmd, invocation.args, { cwd: worktree.path, timeoutMs: opts.timeoutMs });
|
|
187
|
+
return { worktree, result };
|
|
188
|
+
} catch (err) {
|
|
189
|
+
try { await removeTaskWorktree(repoDir, worktree.path, { force: true }); } catch { /* best-effort */ }
|
|
190
|
+
throw err;
|
|
191
|
+
}
|
|
192
|
+
}
|
package/lib/crew/status.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import print from '../print';
|
|
2
2
|
import { requireCrew, shortPath } from './config';
|
|
3
|
-
import { scanTasks,
|
|
3
|
+
import { scanTasks, openPendingQuestions, summarize } from './task';
|
|
4
4
|
import { writeCrewState } from './state';
|
|
5
5
|
|
|
6
6
|
interface StatusOpts { crew?: string; json?: boolean; }
|
|
@@ -8,12 +8,12 @@ interface StatusOpts { crew?: string; json?: boolean; }
|
|
|
8
8
|
export default function crewStatus(opts: StatusOpts): void {
|
|
9
9
|
const crew = requireCrew(opts.crew);
|
|
10
10
|
const tasks = scanTasks(crew);
|
|
11
|
-
const
|
|
11
|
+
const pending = openPendingQuestions(crew);
|
|
12
12
|
const s = summarize(tasks);
|
|
13
13
|
writeCrewState(crew, tasks);
|
|
14
14
|
if (opts.json) {
|
|
15
15
|
// eslint-disable-next-line no-console
|
|
16
|
-
console.log(JSON.stringify({ ok: true, crew: crew.name, vault: crew.vault, summary: s,
|
|
16
|
+
console.log(JSON.stringify({ ok: true, crew: crew.name, vault: crew.vault, summary: s, pendingQuestions: pending.length }, null, 2));
|
|
17
17
|
return;
|
|
18
18
|
}
|
|
19
19
|
print.info(`crew: ${crew.name}`);
|
|
@@ -22,6 +22,6 @@ export default function crewStatus(opts: StatusOpts): void {
|
|
|
22
22
|
// eslint-disable-next-line no-console
|
|
23
23
|
console.log(`tasks: ${s.done}/${s.total} done, ${s.open} open, ${s.blocked} blocked, ${s.doing} doing`);
|
|
24
24
|
// eslint-disable-next-line no-console
|
|
25
|
-
console.log(`
|
|
25
|
+
console.log(`pending questions: ${pending.length} open`);
|
|
26
26
|
}
|
|
27
27
|
|
package/lib/crew/stub.ts
CHANGED
|
@@ -2,8 +2,8 @@ import print from '../print';
|
|
|
2
2
|
|
|
3
3
|
export default function crewStub(name: string): () => void {
|
|
4
4
|
return () => {
|
|
5
|
-
print.warn(`rig
|
|
6
|
-
print.info('Current MVP supports init, status,
|
|
5
|
+
print.warn(`rig orchestrate ${name} is not implemented yet.`);
|
|
6
|
+
print.info('Current MVP supports init, status, pending-questions, board, sync, doctor, ask, and project add/list/status.');
|
|
7
7
|
};
|
|
8
8
|
}
|
|
9
9
|
|
package/lib/crew/task.ts
CHANGED
|
@@ -51,8 +51,8 @@ export function summarize(tasks: CrewTask[]): CrewSummary {
|
|
|
51
51
|
return { total: tasks.length, done, open: tasks.length - done, blocked, doing };
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
export function
|
|
55
|
-
return parseTasks(rootPath(crew, '
|
|
54
|
+
export function openPendingQuestions(crew: CrewEntry): CrewTask[] {
|
|
55
|
+
return parseTasks(rootPath(crew, 'Pending-Questions.md'), 'pending').filter(t => !t.done);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
export function taskProgress(tasks: CrewTask[]): number {
|
|
@@ -75,7 +75,7 @@ function parseTaskLine(line: string): Omit<CrewTask, 'file' | 'line' | 'scope'>
|
|
|
75
75
|
|
|
76
76
|
function taskFiles(crew: CrewEntry): { file: string; scope: string }[] {
|
|
77
77
|
const files = [
|
|
78
|
-
{ file: rootPath(crew, '
|
|
78
|
+
{ file: rootPath(crew, 'Pending-Questions.md'), scope: 'pending' },
|
|
79
79
|
];
|
|
80
80
|
const roles = roleDefinitionsForCrew(crew);
|
|
81
81
|
|
package/lib/crew/vault.ts
CHANGED
|
@@ -21,8 +21,8 @@ export function ensureCrewVault(crew: CrewEntry): void {
|
|
|
21
21
|
writeIfMissing(crewPath(crew, 'tmp/.gitkeep'), '');
|
|
22
22
|
fs.mkdirSync(crewRoot(crew), { recursive: true });
|
|
23
23
|
writeIfMissing(rootPath(crew, 'Current-Goal.md'), '# Current Goal\n\n');
|
|
24
|
-
writeIfMissing(rootPath(crew, '
|
|
25
|
-
writeIfMissing(rootPath(crew, '
|
|
24
|
+
writeIfMissing(rootPath(crew, 'Dashboard.md'), '# Dashboard\n\nCoding agents can run `rig orchestrate board` to refresh this dashboard.\n');
|
|
25
|
+
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');
|
|
26
26
|
|
|
27
27
|
ensureDir(rootPath(crew, 'Shared'));
|
|
28
28
|
writeIfMissing(rootPath(crew, 'Shared/Spec.md'), '# Spec\n\n');
|
|
@@ -82,7 +82,7 @@ function ensureRole(crew: CrewEntry, role: CrewRoleDefinition): void {
|
|
|
82
82
|
const base = rootPath(crew, folder);
|
|
83
83
|
ensureDir(base);
|
|
84
84
|
writeIfMissing(path.join(base, 'Role.md'), renderRoleFile(role));
|
|
85
|
-
if (role.name === '
|
|
85
|
+
if (role.name === 'orchestrator') {
|
|
86
86
|
ensureDir(path.join(base, 'Reports'));
|
|
87
87
|
writeIfMissing(path.join(base, 'Reports', '.gitkeep'), '');
|
|
88
88
|
}
|
|
@@ -267,15 +267,15 @@ function renderVaultAgentInstructions(crew: CrewEntry): string {
|
|
|
267
267
|
AGENT_RULES_START,
|
|
268
268
|
'## Rig Crew',
|
|
269
269
|
'',
|
|
270
|
-
'This Vault uses `rig crew` as an agent-facing coordination layer. Humans talk to the current Claude/Codex coding session; the coding agent uses `rig
|
|
270
|
+
'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.',
|
|
271
271
|
'',
|
|
272
272
|
`- Crew root: \`${root}\``,
|
|
273
|
-
`- Dashboard: \`${root}/
|
|
274
|
-
`-
|
|
273
|
+
`- Dashboard: \`${root}/Dashboard.md\``,
|
|
274
|
+
`- Pending questions (system→user): \`${root}/Pending-Questions.md\``,
|
|
275
275
|
`- Role registry: \`${root}/Shared/Roles.md\``,
|
|
276
276
|
`- Reusable role descriptions: \`${root}/<role>/Role.md\` and \`${root}/Roles/<custom-role>/Role.md\``,
|
|
277
277
|
`- Project owner memory: \`${root}/Projects/<project>/\``,
|
|
278
|
-
`- Per-project pending questions (待补充资料): \`${root}/Projects/<project>/Pending-Questions.md\` — managed by \`rig
|
|
278
|
+
`- Per-project pending questions (待补充资料): \`${root}/Projects/<project>/Pending-Questions.md\` — managed by \`rig orchestrate pending\``,
|
|
279
279
|
`- Project-scoped agent tasks: \`${root}/Projects/<project>/Agents/<role>/Tasks.md\``,
|
|
280
280
|
`- 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.`,
|
|
281
281
|
'- Vault-local scratch projects belong under `tmp/<project>/`.',
|
|
@@ -286,14 +286,14 @@ function renderVaultAgentInstructions(crew: CrewEntry): string {
|
|
|
286
286
|
'',
|
|
287
287
|
'Default collaboration protocol for coding agents:',
|
|
288
288
|
'',
|
|
289
|
-
'0. Do not treat `rig
|
|
290
|
-
'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
|
|
291
|
-
`2. Preferred handoff: run \`rig
|
|
292
|
-
`3. Maintain status awareness before and after work by checking \`${root}/
|
|
289
|
+
'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.',
|
|
290
|
+
'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.',
|
|
291
|
+
`2. Preferred handoff: run \`rig orchestrate "<user request>"\`, then read \`${root}/Dashboard.md\`, \`${root}/Pending-Questions.md\`, and \`${root}/Shared/Roles.md\`.`,
|
|
292
|
+
`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.`,
|
|
293
293
|
`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.`,
|
|
294
|
-
'5. Treat
|
|
295
|
-
'6.
|
|
296
|
-
`7. Worker results must be written back to the relevant role/project files under \`${root}/\`; user-facing questions go to \`${root}/
|
|
294
|
+
'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.',
|
|
295
|
+
'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`.',
|
|
296
|
+
`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.`,
|
|
297
297
|
`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.`,
|
|
298
298
|
'',
|
|
299
299
|
AGENT_RULES_END,
|
package/lib/init/index.ts
CHANGED
|
@@ -6,7 +6,19 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import print from '../print';
|
|
8
8
|
import fs from 'fs';
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
const PACKAGE_RIG_TEMPLATE = `{
|
|
11
|
+
// package.rig.json5 — rig configuration
|
|
12
|
+
// Field reference: see the rig-package / rig-cicd skills.
|
|
13
|
+
dependencies: {
|
|
14
|
+
// 'lib-name': {
|
|
15
|
+
// source: 'git@github.com:org/repo.git', // git ssh url
|
|
16
|
+
// version: '1.0.0', // git tag (semver)
|
|
17
|
+
// dev: false // true => clone into rig_dev/ and develop locally
|
|
18
|
+
// }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
`;
|
|
10
22
|
|
|
11
23
|
export default async () => {
|
|
12
24
|
try {
|
|
@@ -19,11 +31,9 @@ export default async () => {
|
|
|
19
31
|
if (fs.existsSync('package.rig.json5')) {
|
|
20
32
|
print.info('package.rig.json5 already exists~');
|
|
21
33
|
} else {
|
|
22
|
-
//创建package.rig.json5
|
|
34
|
+
//创建package.rig.json5 — 本地模板,留空 dependencies,无示例库引用
|
|
23
35
|
print.info('create package.rig.json5');
|
|
24
|
-
|
|
25
|
-
const packageRigJSON5 = resPackageRigJSON5.data;
|
|
26
|
-
fs.writeFileSync('./package.rig.json5', packageRigJSON5);
|
|
36
|
+
fs.writeFileSync('./package.rig.json5', PACKAGE_RIG_TEMPLATE);
|
|
27
37
|
}
|
|
28
38
|
//检查是否存在rig_helper.js
|
|
29
39
|
// if (fs.existsSync(`${process.cwd()}/rig_helper.js`)) {
|
|
@@ -95,8 +105,7 @@ export default async () => {
|
|
|
95
105
|
postinstall: "rig postinstall",
|
|
96
106
|
},
|
|
97
107
|
devDependencies: {
|
|
98
|
-
json5: '2.2.1'
|
|
99
|
-
"rig-helper": '^1.0.2'
|
|
108
|
+
json5: '2.2.1'
|
|
100
109
|
}
|
|
101
110
|
}
|
|
102
111
|
pkgJSON.private = inserted.private;
|
|
@@ -125,11 +134,9 @@ export default async () => {
|
|
|
125
134
|
}
|
|
126
135
|
if (pkgJSON.devDependencies) {
|
|
127
136
|
pkgJSON.devDependencies.json5 = inserted.devDependencies.json5;
|
|
128
|
-
pkgJSON.devDependencies["rig-helper"] = inserted.devDependencies["rig-helper"];
|
|
129
137
|
} else {
|
|
130
138
|
pkgJSON.devDependencies = inserted.devDependencies;
|
|
131
139
|
}
|
|
132
|
-
//检查是否存在rig-helper
|
|
133
140
|
fs.writeFileSync('package.json', JSON.stringify(pkgJSON, null, 2));
|
|
134
141
|
print.succeed('rig init succeed');
|
|
135
142
|
} catch (e) {
|