@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 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 Install skills and initialize state dir
11
- loom crew [N] "<task>" Spawn N parallel workers on a task
12
- loom crew 2:explore "<task>" Spawn typed workers (explore/plan/code-reviewer)
13
- loom status Show active crew session
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);
@@ -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 { specs, task } = parseWorkerSpec(args);
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, workerPrompt);
52
+ await launchTmux(session.id, totalWorkers, specs, tasks.map(t => t.description), contextPath);
37
53
  }
38
54
  else {
39
- await launchBackground(session.id, totalWorkers, specs, workerPrompt);
55
+ await launchBackground(session.id, specs, tasks.map(t => t.description), contextPath);
40
56
  }
41
- console.log(`\nWorkers launched. Monitor with: loom status`);
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(task, contextPath, sessionId) {
45
- return `You are a worker agent in an agentloom crew session (${sessionId}).
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 job: help complete this task: "${task}"
66
+ Your assigned subtask: "${subtask}"
48
67
 
49
- ## Your protocol
68
+ ## Protocol
50
69
 
51
- 1. Read the shared context at: ${contextPath}
52
- 2. Check ${STATE_DIR}/tasks/ for unclaimed work (files ending in -pending.json)
53
- 3. Claim a task by reading it and noting the task ID
54
- 4. Do the work thoroughly using all tools available to you
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
- - Claim only one task at a time
60
- - Write your findings to the context file so other workers can see them
61
- - Do not stop until you have completed at least one task
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. Check for pending tasks and start working.`;
80
+ Begin now.`;
65
81
  }
66
- async function launchBackground(sessionId, count, specs, prompt) {
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 promptFile = join(STATE_DIR, 'workers', `${workerId}-prompt.md`);
74
- await writeFile(promptFile, prompt);
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', 'ignore', '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, prompt) {
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 { execSync } from 'child_process';
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 = await callClaudeDecompose(task, totalWorkers);
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
- await writeTask(t);
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 escaped = prompt.replace(/'/g, `'"'"'`);
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
- stdio: ['ignore', 'pipe', 'ignore'],
83
- }).trim();
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 = raw.match(/\[[\s\S]*\]/);
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]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chuckssmith/agentloom",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A workflow layer for Claude Code — reusable roles, persistence loops, and multi-agent crew coordination",
5
5
  "keywords": [
6
6
  "ai",