@chuckssmith/agentloom 0.2.0 → 0.3.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/dist/cli.js +14 -4
- package/dist/commands/crew.js +51 -28
- package/dist/commands/logs.d.ts +1 -0
- package/dist/commands/logs.js +53 -0
- package/dist/team/orchestrator.d.ts +1 -1
- package/dist/team/orchestrator.js +10 -9
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2,15 +2,19 @@
|
|
|
2
2
|
import { setup } from './commands/setup.js';
|
|
3
3
|
import { crew } from './commands/crew.js';
|
|
4
4
|
import { status } from './commands/status.js';
|
|
5
|
+
import { logs } from './commands/logs.js';
|
|
5
6
|
const [, , command, ...args] = process.argv;
|
|
6
7
|
const usage = `
|
|
7
8
|
agentloom (loom) — workflow layer for Claude Code
|
|
8
9
|
|
|
9
10
|
Usage:
|
|
10
|
-
loom setup
|
|
11
|
-
loom crew [N] "<task>"
|
|
12
|
-
loom crew 2:explore "<task>"
|
|
13
|
-
loom
|
|
11
|
+
loom setup Install skills and initialize state dir
|
|
12
|
+
loom crew [N] "<task>" Spawn N parallel workers on a task
|
|
13
|
+
loom crew 2:explore "<task>" Spawn typed workers (explore/plan/code-reviewer)
|
|
14
|
+
loom crew --dry-run [N] "<task>" Preview decomposed subtasks without launching
|
|
15
|
+
loom status Show active crew session
|
|
16
|
+
loom logs Show worker output summary
|
|
17
|
+
loom logs <workerId> Show full log for a specific worker
|
|
14
18
|
|
|
15
19
|
Modes (use $grind or $crew inside a Claude Code session):
|
|
16
20
|
$grind Persistence loop — keeps working until verified complete
|
|
@@ -21,6 +25,9 @@ Examples:
|
|
|
21
25
|
loom crew "refactor the auth module"
|
|
22
26
|
loom crew 3 "audit every API endpoint for security issues"
|
|
23
27
|
loom crew 2:explore+1:code-reviewer "review the payment flow"
|
|
28
|
+
loom crew --dry-run 3 "migrate the database schema"
|
|
29
|
+
loom logs
|
|
30
|
+
loom logs w00
|
|
24
31
|
`;
|
|
25
32
|
switch (command) {
|
|
26
33
|
case 'setup':
|
|
@@ -32,6 +39,9 @@ switch (command) {
|
|
|
32
39
|
case 'status':
|
|
33
40
|
await status();
|
|
34
41
|
break;
|
|
42
|
+
case 'logs':
|
|
43
|
+
await logs(args);
|
|
44
|
+
break;
|
|
35
45
|
default:
|
|
36
46
|
console.log(usage);
|
|
37
47
|
process.exit(command ? 1 : 0);
|
package/dist/commands/crew.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execSync, spawn } from 'child_process';
|
|
2
|
-
import { writeFile, mkdir } from 'fs/promises';
|
|
2
|
+
import { writeFile, mkdir, open } from 'fs/promises';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { parseWorkerSpec, initSession, writeContextSnapshot, decomposeTasks, } from '../team/orchestrator.js';
|
|
5
5
|
import { STATE_DIR } from '../state/session.js';
|
|
@@ -15,15 +15,32 @@ const hasTmux = () => {
|
|
|
15
15
|
const isWSL = () => process.platform === 'linux' && !!process.env.WSL_DISTRO_NAME;
|
|
16
16
|
export async function crew(args) {
|
|
17
17
|
if (args.length === 0) {
|
|
18
|
-
console.error('Usage: loom crew [N] "<task>"');
|
|
18
|
+
console.error('Usage: loom crew [--dry-run] [N] "<task>"');
|
|
19
19
|
process.exit(1);
|
|
20
20
|
}
|
|
21
|
-
const
|
|
21
|
+
const dryRun = args.includes('--dry-run');
|
|
22
|
+
const filteredArgs = args.filter(a => a !== '--dry-run');
|
|
23
|
+
const { specs, task } = parseWorkerSpec(filteredArgs);
|
|
22
24
|
const totalWorkers = specs.reduce((sum, s) => sum + s.count, 0);
|
|
23
25
|
const slug = task.slice(0, 30).toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
|
24
26
|
console.log(`\nagentloom crew`);
|
|
25
27
|
console.log(`Task: ${task}`);
|
|
26
28
|
console.log(`Workers: ${totalWorkers}`);
|
|
29
|
+
if (dryRun) {
|
|
30
|
+
console.log(`Mode: dry-run\n`);
|
|
31
|
+
console.log('Decomposing task...\n');
|
|
32
|
+
const tasks = await decomposeTasks(task, specs, true);
|
|
33
|
+
let idx = 0;
|
|
34
|
+
for (const spec of specs) {
|
|
35
|
+
for (let i = 0; i < spec.count; i++) {
|
|
36
|
+
const t = tasks[idx++];
|
|
37
|
+
console.log(` [w${String(idx - 1).padStart(2, '0')}] (${spec.agentType})`);
|
|
38
|
+
console.log(` ${t?.description ?? task}\n`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
console.log('Run without --dry-run to launch workers.');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
27
44
|
console.log(`Mode: ${hasTmux() && !isWSL() ? 'tmux' : 'background processes'}\n`);
|
|
28
45
|
const session = await initSession(task, totalWorkers);
|
|
29
46
|
const contextPath = await writeContextSnapshot(slug, task);
|
|
@@ -31,65 +48,71 @@ export async function crew(args) {
|
|
|
31
48
|
console.log(`Session: ${session.id}`);
|
|
32
49
|
console.log(`Tasks: ${tasks.length} created`);
|
|
33
50
|
console.log(`Context: ${contextPath}\n`);
|
|
34
|
-
const workerPrompt = buildWorkerPrompt(task, contextPath, session.id);
|
|
35
51
|
if (hasTmux() && !isWSL()) {
|
|
36
|
-
await launchTmux(session.id, totalWorkers, specs,
|
|
52
|
+
await launchTmux(session.id, totalWorkers, specs, tasks.map(t => t.description), contextPath);
|
|
37
53
|
}
|
|
38
54
|
else {
|
|
39
|
-
await launchBackground(session.id,
|
|
55
|
+
await launchBackground(session.id, specs, tasks.map(t => t.description), contextPath);
|
|
40
56
|
}
|
|
41
|
-
console.log(`\nWorkers launched. Monitor with
|
|
57
|
+
console.log(`\nWorkers launched. Monitor with:`);
|
|
58
|
+
console.log(` loom status`);
|
|
59
|
+
console.log(` loom logs`);
|
|
42
60
|
console.log(`State dir: ${STATE_DIR}/`);
|
|
43
61
|
}
|
|
44
|
-
function buildWorkerPrompt(
|
|
45
|
-
|
|
62
|
+
function buildWorkerPrompt(subtask, contextPath, sessionId, workerId) {
|
|
63
|
+
const resultFile = join(STATE_DIR, 'workers', `${workerId}-result.md`);
|
|
64
|
+
return `You are worker ${workerId} in an agentloom crew session (${sessionId}).
|
|
46
65
|
|
|
47
|
-
Your
|
|
66
|
+
Your assigned subtask: "${subtask}"
|
|
48
67
|
|
|
49
|
-
##
|
|
68
|
+
## Protocol
|
|
50
69
|
|
|
51
|
-
1. Read the shared context
|
|
52
|
-
2.
|
|
53
|
-
3.
|
|
54
|
-
|
|
55
|
-
5. Write your result back to ${STATE_DIR}/workers/
|
|
56
|
-
6. Repeat until no pending tasks remain
|
|
70
|
+
1. Read the shared context: ${contextPath}
|
|
71
|
+
2. Do the work thoroughly using all tools available to you
|
|
72
|
+
3. When done, write a result summary to: ${resultFile}
|
|
73
|
+
Format: brief markdown — what you did, what you found, any blockers
|
|
57
74
|
|
|
58
75
|
## Rules
|
|
59
|
-
-
|
|
60
|
-
- Write
|
|
61
|
-
- Do not stop until you have
|
|
62
|
-
- If all tasks are claimed, do exploratory work relevant to the main task
|
|
76
|
+
- Focus only on your assigned subtask
|
|
77
|
+
- Write findings to the context file (${contextPath}) so other workers can see them
|
|
78
|
+
- Do not stop until your subtask is complete or you have hit a genuine blocker
|
|
63
79
|
|
|
64
|
-
Begin now
|
|
80
|
+
Begin now.`;
|
|
65
81
|
}
|
|
66
|
-
async function launchBackground(sessionId,
|
|
82
|
+
async function launchBackground(sessionId, specs, subtasks, contextPath) {
|
|
67
83
|
await mkdir(join(STATE_DIR, 'workers'), { recursive: true });
|
|
68
84
|
let workerIdx = 0;
|
|
69
85
|
for (const spec of specs) {
|
|
70
86
|
for (let i = 0; i < spec.count; i++) {
|
|
71
87
|
const workerId = `w${String(workerIdx).padStart(2, '0')}`;
|
|
88
|
+
const subtask = subtasks[workerIdx] ?? subtasks[0] ?? '';
|
|
72
89
|
workerIdx++;
|
|
73
|
-
const
|
|
74
|
-
|
|
90
|
+
const prompt = buildWorkerPrompt(subtask, contextPath, sessionId, workerId);
|
|
91
|
+
const logFile = join(STATE_DIR, 'workers', `${workerId}.log`);
|
|
92
|
+
// Write prompt to disk for inspection
|
|
93
|
+
await writeFile(join(STATE_DIR, 'workers', `${workerId}-prompt.md`), prompt);
|
|
94
|
+
const log = await open(logFile, 'w');
|
|
75
95
|
const child = spawn('claude', ['--print', '--dangerously-skip-permissions', '-p', prompt], {
|
|
76
96
|
detached: true,
|
|
77
|
-
stdio: ['ignore',
|
|
97
|
+
stdio: ['ignore', log.fd, log.fd],
|
|
78
98
|
env: { ...process.env, AGENTLOOM_WORKER_ID: workerId, AGENTLOOM_SESSION: sessionId },
|
|
79
99
|
});
|
|
100
|
+
child.on('close', () => log.close());
|
|
80
101
|
child.unref();
|
|
81
|
-
console.log(` ✓ Worker ${workerId} (${spec.agentType}) launched [pid ${child.pid}]`);
|
|
102
|
+
console.log(` ✓ Worker ${workerId} (${spec.agentType}) launched [pid ${child.pid}] → ${logFile}`);
|
|
82
103
|
}
|
|
83
104
|
}
|
|
84
105
|
}
|
|
85
|
-
async function launchTmux(sessionId, count, specs,
|
|
106
|
+
async function launchTmux(sessionId, count, specs, subtasks, contextPath) {
|
|
86
107
|
const tmuxSession = `loom-${sessionId}`;
|
|
87
108
|
execSync(`tmux new-session -d -s ${tmuxSession} -x 220 -y 50`);
|
|
88
109
|
let workerIdx = 0;
|
|
89
110
|
for (const spec of specs) {
|
|
90
111
|
for (let i = 0; i < spec.count; i++) {
|
|
91
112
|
const workerId = `w${String(workerIdx).padStart(2, '0')}`;
|
|
113
|
+
const subtask = subtasks[workerIdx] ?? subtasks[0] ?? '';
|
|
92
114
|
workerIdx++;
|
|
115
|
+
const prompt = buildWorkerPrompt(subtask, contextPath, sessionId, workerId);
|
|
93
116
|
if (workerIdx > 1) {
|
|
94
117
|
execSync(`tmux split-window -h -t ${tmuxSession}`);
|
|
95
118
|
execSync(`tmux select-layout -t ${tmuxSession} tiled`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function logs(args: string[]): Promise<void>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readFile, readdir } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { STATE_DIR } from '../state/session.js';
|
|
5
|
+
const WORKERS_DIR = join(STATE_DIR, 'workers');
|
|
6
|
+
export async function logs(args) {
|
|
7
|
+
if (!existsSync(WORKERS_DIR)) {
|
|
8
|
+
console.log('No worker logs found. Run: loom crew "<task>"');
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const workerId = args[0];
|
|
12
|
+
if (workerId) {
|
|
13
|
+
// Show log for a specific worker
|
|
14
|
+
const logFile = join(WORKERS_DIR, `${workerId}.log`);
|
|
15
|
+
const resultFile = join(WORKERS_DIR, `${workerId}-result.md`);
|
|
16
|
+
if (existsSync(logFile)) {
|
|
17
|
+
console.log(`\n── ${workerId} log ──────────────────────────────`);
|
|
18
|
+
console.log(await readFile(logFile, 'utf8'));
|
|
19
|
+
}
|
|
20
|
+
if (existsSync(resultFile)) {
|
|
21
|
+
console.log(`\n── ${workerId} result ─────────────────────────`);
|
|
22
|
+
console.log(await readFile(resultFile, 'utf8'));
|
|
23
|
+
}
|
|
24
|
+
if (!existsSync(logFile) && !existsSync(resultFile)) {
|
|
25
|
+
console.log(`No logs found for worker: ${workerId}`);
|
|
26
|
+
}
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
// List all workers with status summary
|
|
30
|
+
const files = await readdir(WORKERS_DIR);
|
|
31
|
+
const logFiles = files.filter(f => f.endsWith('.log')).sort();
|
|
32
|
+
if (logFiles.length === 0) {
|
|
33
|
+
console.log('No worker logs yet.');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
for (const logFile of logFiles) {
|
|
37
|
+
const id = logFile.replace('.log', '');
|
|
38
|
+
const content = await readFile(join(WORKERS_DIR, logFile), 'utf8');
|
|
39
|
+
const lines = content.trim().split('\n');
|
|
40
|
+
const hasResult = existsSync(join(WORKERS_DIR, `${id}-result.md`));
|
|
41
|
+
const status = hasResult ? 'done' : lines.length > 0 ? 'running/stopped' : 'empty';
|
|
42
|
+
const lastLine = lines.filter(l => l.trim()).at(-1)?.slice(0, 80) ?? '';
|
|
43
|
+
console.log(`\n[${id}] ${status}`);
|
|
44
|
+
if (lastLine)
|
|
45
|
+
console.log(` ${lastLine}`);
|
|
46
|
+
if (hasResult) {
|
|
47
|
+
const result = await readFile(join(WORKERS_DIR, `${id}-result.md`), 'utf8');
|
|
48
|
+
const firstLine = result.trim().split('\n')[0] ?? '';
|
|
49
|
+
console.log(` result: ${firstLine}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
console.log(`\nFull log: loom logs <workerId>`);
|
|
53
|
+
}
|
|
@@ -9,4 +9,4 @@ export declare function parseWorkerSpec(args: string[]): {
|
|
|
9
9
|
};
|
|
10
10
|
export declare function initSession(description: string, workerCount: number): Promise<Session>;
|
|
11
11
|
export declare function writeContextSnapshot(slug: string, task: string): Promise<string>;
|
|
12
|
-
export declare function decomposeTasks(task: string, specs: WorkerSpec[]): Promise<Task[]>;
|
|
12
|
+
export declare function decomposeTasks(task: string, specs: WorkerSpec[], dryRun?: boolean): Promise<Task[]>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { writeFile } from 'fs/promises';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { randomUUID } from 'crypto';
|
|
4
|
-
import {
|
|
4
|
+
import { spawnSync } from 'child_process';
|
|
5
5
|
import { STATE_DIR, ensureStateDir, writeSession, writeTask } from '../state/session.js';
|
|
6
6
|
export function parseWorkerSpec(args) {
|
|
7
7
|
// Formats:
|
|
@@ -47,9 +47,9 @@ export async function writeContextSnapshot(slug, task) {
|
|
|
47
47
|
await writeFile(path, content);
|
|
48
48
|
return path;
|
|
49
49
|
}
|
|
50
|
-
export async function decomposeTasks(task, specs) {
|
|
50
|
+
export async function decomposeTasks(task, specs, dryRun = false) {
|
|
51
51
|
const totalWorkers = specs.reduce((sum, s) => sum + s.count, 0);
|
|
52
|
-
const subtasks =
|
|
52
|
+
const subtasks = callClaudeDecompose(task, totalWorkers);
|
|
53
53
|
const tasks = [];
|
|
54
54
|
let idx = 0;
|
|
55
55
|
for (const spec of specs) {
|
|
@@ -62,7 +62,8 @@ export async function decomposeTasks(task, specs) {
|
|
|
62
62
|
createdAt: new Date().toISOString(),
|
|
63
63
|
};
|
|
64
64
|
tasks.push(t);
|
|
65
|
-
|
|
65
|
+
if (!dryRun)
|
|
66
|
+
await writeTask(t);
|
|
66
67
|
idx++;
|
|
67
68
|
}
|
|
68
69
|
}
|
|
@@ -75,14 +76,14 @@ function callClaudeDecompose(task, n) {
|
|
|
75
76
|
|
|
76
77
|
Task: "${task}"`;
|
|
77
78
|
try {
|
|
78
|
-
const
|
|
79
|
-
const raw = execSync(`claude --print -p '${escaped}'`, {
|
|
79
|
+
const result = spawnSync('claude', ['--print', '-p', prompt], {
|
|
80
80
|
encoding: 'utf8',
|
|
81
81
|
timeout: 30_000,
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
});
|
|
83
|
+
if (result.status !== 0 || !result.stdout)
|
|
84
|
+
throw new Error(result.stderr ?? 'no output');
|
|
84
85
|
// Extract JSON array from the response (strip any surrounding prose)
|
|
85
|
-
const match =
|
|
86
|
+
const match = result.stdout.match(/\[[\s\S]*\]/);
|
|
86
87
|
if (!match)
|
|
87
88
|
throw new Error('No JSON array in response');
|
|
88
89
|
const parsed = JSON.parse(match[0]);
|