@cluesmith/codev 2.0.0-rc.71 → 2.0.0-rc.73

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.
Files changed (57) hide show
  1. package/dashboard/dist/assets/{index-C7FtNK6Y.css → index-4n9zpWLY.css} +1 -1
  2. package/dashboard/dist/assets/{index-CDAINZKT.js → index-CH_utkcW.js} +32 -27
  3. package/dashboard/dist/assets/index-CH_utkcW.js.map +1 -0
  4. package/dashboard/dist/index.html +2 -2
  5. package/dist/agent-farm/commands/send.d.ts.map +1 -1
  6. package/dist/agent-farm/commands/send.js +17 -7
  7. package/dist/agent-farm/commands/send.js.map +1 -1
  8. package/dist/agent-farm/commands/spawn-roles.d.ts +80 -0
  9. package/dist/agent-farm/commands/spawn-roles.d.ts.map +1 -0
  10. package/dist/agent-farm/commands/spawn-roles.js +278 -0
  11. package/dist/agent-farm/commands/spawn-roles.js.map +1 -0
  12. package/dist/agent-farm/commands/spawn-worktree.d.ts +96 -0
  13. package/dist/agent-farm/commands/spawn-worktree.d.ts.map +1 -0
  14. package/dist/agent-farm/commands/spawn-worktree.js +305 -0
  15. package/dist/agent-farm/commands/spawn-worktree.js.map +1 -0
  16. package/dist/agent-farm/commands/spawn.d.ts +5 -1
  17. package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
  18. package/dist/agent-farm/commands/spawn.js +65 -725
  19. package/dist/agent-farm/commands/spawn.js.map +1 -1
  20. package/dist/agent-farm/servers/tower-instances.d.ts +82 -0
  21. package/dist/agent-farm/servers/tower-instances.d.ts.map +1 -0
  22. package/dist/agent-farm/servers/tower-instances.js +441 -0
  23. package/dist/agent-farm/servers/tower-instances.js.map +1 -0
  24. package/dist/agent-farm/servers/tower-routes.d.ts +34 -0
  25. package/dist/agent-farm/servers/tower-routes.d.ts.map +1 -0
  26. package/dist/agent-farm/servers/tower-routes.js +1445 -0
  27. package/dist/agent-farm/servers/tower-routes.js.map +1 -0
  28. package/dist/agent-farm/servers/tower-server.d.ts +5 -2
  29. package/dist/agent-farm/servers/tower-server.d.ts.map +1 -1
  30. package/dist/agent-farm/servers/tower-server.js +74 -2860
  31. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  32. package/dist/agent-farm/servers/tower-terminals.d.ts +119 -0
  33. package/dist/agent-farm/servers/tower-terminals.d.ts.map +1 -0
  34. package/dist/agent-farm/servers/tower-terminals.js +629 -0
  35. package/dist/agent-farm/servers/tower-terminals.js.map +1 -0
  36. package/dist/agent-farm/servers/tower-tunnel.d.ts +34 -0
  37. package/dist/agent-farm/servers/tower-tunnel.d.ts.map +1 -0
  38. package/dist/agent-farm/servers/tower-tunnel.js +299 -0
  39. package/dist/agent-farm/servers/tower-tunnel.js.map +1 -0
  40. package/dist/agent-farm/servers/tower-types.d.ts +85 -0
  41. package/dist/agent-farm/servers/tower-types.d.ts.map +1 -0
  42. package/dist/agent-farm/servers/tower-types.js +6 -0
  43. package/dist/agent-farm/servers/tower-types.js.map +1 -0
  44. package/dist/agent-farm/servers/tower-utils.d.ts +51 -0
  45. package/dist/agent-farm/servers/tower-utils.d.ts.map +1 -0
  46. package/dist/agent-farm/servers/tower-utils.js +161 -0
  47. package/dist/agent-farm/servers/tower-utils.js.map +1 -0
  48. package/dist/agent-farm/servers/tower-websocket.d.ts +25 -0
  49. package/dist/agent-farm/servers/tower-websocket.d.ts.map +1 -0
  50. package/dist/agent-farm/servers/tower-websocket.js +171 -0
  51. package/dist/agent-farm/servers/tower-websocket.js.map +1 -0
  52. package/dist/commands/init.d.ts.map +1 -1
  53. package/dist/commands/init.js +11 -1
  54. package/dist/commands/init.js.map +1 -1
  55. package/package.json +1 -1
  56. package/templates/tower.html +11 -20
  57. package/dashboard/dist/assets/index-CDAINZKT.js.map +0 -1
@@ -1,141 +1,25 @@
1
1
  /**
2
- * Spawn command - creates a new builder in various modes
2
+ * Spawn command orchestrator module.
3
+ * Spec 0105: Tower Server Decomposition — Phase 7
3
4
  *
4
5
  * Modes:
5
6
  * - spec: --project/-p Spawn for a spec file (existing behavior)
6
7
  * - task: --task Spawn with an ad-hoc task description
7
8
  * - protocol: --protocol Spawn to run a protocol (cleanup, experiment, etc.)
8
9
  * - shell: --shell Bare Claude session (no prompt, no worktree)
10
+ *
11
+ * Role/prompt logic extracted to spawn-roles.ts.
12
+ * Worktree/git logic extracted to spawn-worktree.ts.
9
13
  */
10
14
  import { resolve, basename } from 'node:path';
11
- import { existsSync, readFileSync, writeFileSync, chmodSync, readdirSync, symlinkSync } from 'node:fs';
12
- import { readdir } from 'node:fs/promises';
15
+ import { existsSync, writeFileSync } from 'node:fs';
13
16
  import { getConfig, ensureDirectories, getResolvedCommands } from '../utils/index.js';
14
17
  import { logger, fatal } from '../utils/logger.js';
15
- import { run, commandExists } from '../utils/shell.js';
18
+ import { run } from '../utils/shell.js';
16
19
  import { upsertBuilder } from '../state.js';
17
20
  import { loadRolePrompt } from '../utils/roles.js';
18
- // Tower port the single HTTP server since Spec 0090
19
- const DEFAULT_TOWER_PORT = 4100;
20
- /**
21
- * Simple Handlebars-like template renderer
22
- * Supports: {{variable}}, {{#if condition}}...{{/if}}, {{object.property}}
23
- */
24
- function renderTemplate(template, context) {
25
- let result = template;
26
- // Process {{#if condition}}...{{/if}} blocks
27
- // eslint-disable-next-line no-constant-condition
28
- while (true) {
29
- const ifMatch = result.match(/\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/);
30
- if (!ifMatch)
31
- break;
32
- const [fullMatch, condition, content] = ifMatch;
33
- const value = getNestedValue(context, condition);
34
- result = result.replace(fullMatch, value ? content : '');
35
- }
36
- // Process {{variable}} and {{object.property}} substitutions
37
- result = result.replace(/\{\{(\w+(?:\.\w+)*)\}\}/g, (_, path) => {
38
- const value = getNestedValue(context, path);
39
- if (value === undefined || value === null)
40
- return '';
41
- return String(value);
42
- });
43
- // Clean up any double newlines left from removed sections
44
- result = result.replace(/\n{3,}/g, '\n\n');
45
- return result.trim();
46
- }
47
- /**
48
- * Get nested value from object using dot notation
49
- */
50
- function getNestedValue(obj, path) {
51
- const parts = path.split('.');
52
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
53
- let current = obj;
54
- for (const part of parts) {
55
- if (current === null || current === undefined)
56
- return undefined;
57
- current = current[part];
58
- }
59
- return current;
60
- }
61
- /**
62
- * Load builder-prompt.md template for a protocol
63
- */
64
- function loadBuilderPromptTemplate(config, protocolName) {
65
- const templatePath = resolve(config.codevDir, 'protocols', protocolName, 'builder-prompt.md');
66
- if (existsSync(templatePath)) {
67
- return readFileSync(templatePath, 'utf-8');
68
- }
69
- return null;
70
- }
71
- /**
72
- * Build the prompt using protocol template or fallback to inline prompt
73
- */
74
- function buildPromptFromTemplate(config, protocolName, context) {
75
- const template = loadBuilderPromptTemplate(config, protocolName);
76
- if (template) {
77
- logger.info(`Using template: protocols/${protocolName}/builder-prompt.md`);
78
- return renderTemplate(template, context);
79
- }
80
- // Fallback: no template found, return a basic prompt
81
- logger.debug(`No template found for ${protocolName}, using inline prompt`);
82
- return buildFallbackPrompt(protocolName, context);
83
- }
84
- /**
85
- * Build a fallback prompt when no template exists
86
- */
87
- function buildFallbackPrompt(protocolName, context) {
88
- const modeInstructions = context.mode === 'strict'
89
- ? `## Mode: STRICT
90
- Porch orchestrates your work. Run: \`porch next\` to get your next tasks.`
91
- : `## Mode: SOFT
92
- You follow the protocol yourself. The architect monitors your work and verifies compliance.`;
93
- let prompt = `# ${protocolName.toUpperCase()} Builder (${context.mode} mode)
94
-
95
- You are implementing ${context.input_description}.
96
-
97
- ${modeInstructions}
98
-
99
- ## Protocol
100
- Follow the ${protocolName.toUpperCase()} protocol: \`codev/protocols/${protocolName}/protocol.md\`
101
- Read and internalize the protocol before starting any work.
102
- `;
103
- if (context.spec) {
104
- prompt += `\n## Spec\nRead the specification at: \`${context.spec.path}\`\n`;
105
- }
106
- if (context.plan) {
107
- prompt += `\n## Plan\nFollow the implementation plan at: \`${context.plan.path}\`\n`;
108
- }
109
- if (context.issue) {
110
- prompt += `\n## Issue #${context.issue.number}
111
- **Title**: ${context.issue.title}
112
-
113
- **Description**:
114
- ${context.issue.body || '(No description provided)'}
115
- `;
116
- }
117
- if (context.task_text) {
118
- prompt += `\n## Task\n${context.task_text}\n`;
119
- }
120
- return prompt;
121
- }
122
- // =============================================================================
123
- // Resume Context
124
- // =============================================================================
125
- /**
126
- * Build a resume notice to prepend to the builder prompt.
127
- * Tells the builder this is a resumed session and to check existing porch state.
128
- */
129
- function buildResumeNotice(_projectId) {
130
- return `## RESUME SESSION
131
-
132
- This is a **resumed** builder session. A previous session was working in this worktree.
133
-
134
- Start by running \`porch next\` to check your current state and get next tasks.
135
- If porch state exists, continue from where the previous session left off.
136
- If porch reports "not found", run \`porch init\` to re-initialize.
137
- `;
138
- }
21
+ import { buildPromptFromTemplate, buildResumeNotice, loadProtocolRole, findSpecFile, validateProtocol, loadProtocol, resolveProtocol, resolveMode, } from './spawn-roles.js';
22
+ import { DEFAULT_TOWER_PORT, checkDependencies, createWorktree, initPorchInWorktree, checkBugfixCollisions, fetchGitHubIssue, executePreSpawnHooks, slugify, validateResumeWorktree, createPtySession, startBuilderSession, startShellSession, buildWorktreeLaunchScript, } from './spawn-worktree.js';
139
23
  // =============================================================================
140
24
  // ID and Session Management
141
25
  // =============================================================================
@@ -213,334 +97,6 @@ function getSpawnMode(options) {
213
97
  return 'protocol';
214
98
  throw new Error('No mode specified');
215
99
  }
216
- // loadRolePrompt imported from ../utils/roles.js
217
- /**
218
- * Load a protocol-specific role if it exists
219
- */
220
- function loadProtocolRole(config, protocolName) {
221
- const protocolRolePath = resolve(config.codevDir, 'protocols', protocolName, 'role.md');
222
- if (existsSync(protocolRolePath)) {
223
- return { content: readFileSync(protocolRolePath, 'utf-8'), source: 'protocol' };
224
- }
225
- // Fall back to builder role
226
- return loadRolePrompt(config, 'builder');
227
- }
228
- /**
229
- * Find a spec file by project ID
230
- */
231
- async function findSpecFile(codevDir, projectId) {
232
- const specsDir = resolve(codevDir, 'specs');
233
- if (!existsSync(specsDir)) {
234
- return null;
235
- }
236
- const files = await readdir(specsDir);
237
- // Try exact match first (e.g., "0001-feature.md")
238
- for (const file of files) {
239
- if (file.startsWith(projectId) && file.endsWith('.md')) {
240
- return resolve(specsDir, file);
241
- }
242
- }
243
- // Try partial match (e.g., just "0001")
244
- for (const file of files) {
245
- if (file.startsWith(projectId + '-') && file.endsWith('.md')) {
246
- return resolve(specsDir, file);
247
- }
248
- }
249
- return null;
250
- }
251
- /**
252
- * Validate that a protocol exists
253
- */
254
- function validateProtocol(config, protocolName) {
255
- const protocolDir = resolve(config.codevDir, 'protocols', protocolName);
256
- const protocolFile = resolve(protocolDir, 'protocol.md');
257
- if (!existsSync(protocolDir)) {
258
- // List available protocols
259
- const protocolsDir = resolve(config.codevDir, 'protocols');
260
- let available = '';
261
- if (existsSync(protocolsDir)) {
262
- const dirs = readdirSync(protocolsDir, { withFileTypes: true })
263
- .filter((d) => d.isDirectory())
264
- .map((d) => d.name);
265
- if (dirs.length > 0) {
266
- available = `\n\nAvailable protocols: ${dirs.join(', ')}`;
267
- }
268
- }
269
- fatal(`Protocol not found: ${protocolName}${available}`);
270
- }
271
- if (!existsSync(protocolFile)) {
272
- fatal(`Protocol ${protocolName} exists but has no protocol.md file`);
273
- }
274
- }
275
- /**
276
- * Load and parse a protocol.json file
277
- */
278
- function loadProtocol(config, protocolName) {
279
- const protocolJsonPath = resolve(config.codevDir, 'protocols', protocolName, 'protocol.json');
280
- if (!existsSync(protocolJsonPath)) {
281
- return null;
282
- }
283
- try {
284
- const content = readFileSync(protocolJsonPath, 'utf-8');
285
- return JSON.parse(content);
286
- }
287
- catch {
288
- logger.warn(`Warning: Failed to parse ${protocolJsonPath}`);
289
- return null;
290
- }
291
- }
292
- /**
293
- * Resolve which protocol to use based on precedence:
294
- * 1. Explicit --protocol flag when used as override (with other input modes)
295
- * 2. Explicit --use-protocol flag (backwards compatibility)
296
- * 3. Spec file **Protocol**: header (for --project mode)
297
- * 4. Hardcoded defaults (spir for specs, bugfix for issues)
298
- */
299
- async function resolveProtocol(options, config) {
300
- // Count input modes to determine if --protocol is being used as override
301
- const inputModes = [
302
- options.project,
303
- options.task,
304
- options.shell,
305
- options.worktree,
306
- options.issue,
307
- ].filter(Boolean);
308
- const protocolAsOverride = options.protocol && inputModes.length > 0;
309
- // 1. --protocol as override always wins when combined with other input modes
310
- if (protocolAsOverride) {
311
- validateProtocol(config, options.protocol);
312
- return options.protocol.toLowerCase();
313
- }
314
- // 2. Explicit --use-protocol override (backwards compatibility)
315
- if (options.useProtocol) {
316
- validateProtocol(config, options.useProtocol);
317
- return options.useProtocol.toLowerCase();
318
- }
319
- // 3. For spec mode, check spec file header (preserves existing behavior)
320
- if (options.project) {
321
- const specFile = await findSpecFile(config.codevDir, options.project);
322
- if (specFile) {
323
- const specContent = readFileSync(specFile, 'utf-8');
324
- const match = specContent.match(/\*\*Protocol\*\*:\s*(\w+)/i);
325
- if (match) {
326
- const protocolFromSpec = match[1].toLowerCase();
327
- // Validate the protocol exists
328
- try {
329
- validateProtocol(config, protocolFromSpec);
330
- return protocolFromSpec;
331
- }
332
- catch {
333
- // If protocol from spec doesn't exist, fall through to defaults
334
- logger.warn(`Warning: Protocol "${match[1]}" from spec not found, using default`);
335
- }
336
- }
337
- }
338
- }
339
- // 4. Hardcoded defaults based on input type
340
- if (options.project)
341
- return 'spir';
342
- if (options.issue)
343
- return 'bugfix';
344
- // --protocol alone (not as override) uses the protocol name itself
345
- if (options.protocol)
346
- return options.protocol.toLowerCase();
347
- if (options.task)
348
- return 'spir';
349
- return 'spir'; // Final fallback
350
- }
351
- // Note: GitHubIssue interface is defined later in the file
352
- /**
353
- * Resolve the builder mode (strict vs soft)
354
- * Precedence:
355
- * 1. Explicit --strict or --soft flags (always win)
356
- * 2. Protocol defaults from protocol.json
357
- * 3. Input type defaults (spec = strict, all others = soft)
358
- */
359
- function resolveMode(options, protocol) {
360
- // 1. Explicit flags always win
361
- if (options.strict && options.soft) {
362
- fatal('--strict and --soft are mutually exclusive');
363
- }
364
- if (options.strict) {
365
- return 'strict';
366
- }
367
- if (options.soft) {
368
- return 'soft';
369
- }
370
- // 2. Protocol defaults from protocol.json
371
- if (protocol?.defaults?.mode) {
372
- return protocol.defaults.mode;
373
- }
374
- // 3. Input type defaults: only spec mode defaults to strict
375
- if (options.project) {
376
- return 'strict';
377
- }
378
- // All other modes default to soft
379
- return 'soft';
380
- }
381
- /**
382
- * Execute pre-spawn hooks defined in protocol.json
383
- * Hooks are data-driven but reuse existing implementation logic
384
- */
385
- async function executePreSpawnHooks(protocol, context) {
386
- if (!protocol?.hooks?.['pre-spawn'])
387
- return;
388
- const hooks = protocol.hooks['pre-spawn'];
389
- // collision-check: reuses existing checkBugfixCollisions() logic
390
- if (hooks['collision-check'] && context.issueNumber && context.issue && context.worktreePath) {
391
- await checkBugfixCollisions(context.issueNumber, context.worktreePath, context.issue, !!context.force);
392
- }
393
- // comment-on-issue: posts comment to GitHub issue
394
- if (hooks['comment-on-issue'] && context.issueNumber && !context.noComment) {
395
- const message = hooks['comment-on-issue'];
396
- logger.info('Commenting on issue...');
397
- try {
398
- await run(`gh issue comment ${context.issueNumber} --body "${message}"`);
399
- }
400
- catch {
401
- logger.warn('Warning: Failed to comment on issue (continuing anyway)');
402
- }
403
- }
404
- }
405
- /**
406
- * Check for required dependencies
407
- */
408
- async function checkDependencies() {
409
- if (!(await commandExists('git'))) {
410
- fatal('git not found');
411
- }
412
- }
413
- /**
414
- * Create git branch and worktree
415
- */
416
- async function createWorktree(config, branchName, worktreePath) {
417
- logger.info('Creating branch...');
418
- try {
419
- await run(`git branch ${branchName}`, { cwd: config.projectRoot });
420
- }
421
- catch (error) {
422
- // Branch might already exist, that's OK
423
- logger.debug(`Branch creation: ${error}`);
424
- }
425
- logger.info('Creating worktree...');
426
- try {
427
- await run(`git worktree add "${worktreePath}" ${branchName}`, { cwd: config.projectRoot });
428
- }
429
- catch (error) {
430
- fatal(`Failed to create worktree: ${error}`);
431
- }
432
- // Symlink .env from project root into worktree (if it exists)
433
- const rootEnvPath = resolve(config.projectRoot, '.env');
434
- const worktreeEnvPath = resolve(worktreePath, '.env');
435
- if (existsSync(rootEnvPath) && !existsSync(worktreeEnvPath)) {
436
- try {
437
- symlinkSync(rootEnvPath, worktreeEnvPath);
438
- logger.info('Linked .env from project root');
439
- }
440
- catch (error) {
441
- logger.debug(`Failed to symlink .env: ${error}`);
442
- }
443
- }
444
- }
445
- /**
446
- * Create a terminal session via the Tower REST API.
447
- * The Tower server must be running (port 4100).
448
- */
449
- async function createPtySession(config, command, args, cwd, registration) {
450
- const body = { command, args, cwd, cols: 200, rows: 50, persistent: true };
451
- if (registration) {
452
- body.projectPath = registration.projectPath;
453
- body.type = registration.type;
454
- body.roleId = registration.roleId;
455
- }
456
- const response = await fetch(`http://localhost:${DEFAULT_TOWER_PORT}/api/terminals`, {
457
- method: 'POST',
458
- headers: { 'Content-Type': 'application/json' },
459
- body: JSON.stringify(body),
460
- });
461
- if (!response.ok) {
462
- const text = await response.text();
463
- throw new Error(`Failed to create PTY session: ${response.status} ${text}`);
464
- }
465
- const result = await response.json();
466
- return { terminalId: result.id };
467
- }
468
- /**
469
- * Start a terminal session for a builder
470
- */
471
- async function startBuilderSession(config, builderId, worktreePath, baseCmd, prompt, roleContent, roleSource) {
472
- logger.info('Creating terminal session...');
473
- // Write initial prompt to a file for reference
474
- const promptFile = resolve(worktreePath, '.builder-prompt.txt');
475
- writeFileSync(promptFile, prompt);
476
- // Build the start script with role if provided
477
- const scriptPath = resolve(worktreePath, '.builder-start.sh');
478
- let scriptContent;
479
- if (roleContent) {
480
- // Write role to a file and use $(cat) to avoid shell escaping issues
481
- const roleFile = resolve(worktreePath, '.builder-role.md');
482
- // Inject the actual dashboard port into the role prompt
483
- const roleWithPort = roleContent.replace(/\{PORT\}/g, String(DEFAULT_TOWER_PORT));
484
- writeFileSync(roleFile, roleWithPort);
485
- logger.info(`Loaded role (${roleSource})`);
486
- scriptContent = `#!/bin/bash
487
- cd "${worktreePath}"
488
- while true; do
489
- ${baseCmd} --append-system-prompt "$(cat '${roleFile}')" "$(cat '${promptFile}')"
490
- echo ""
491
- echo "Claude exited. Restarting in 2 seconds... (Ctrl+C to quit)"
492
- sleep 2
493
- done
494
- `;
495
- }
496
- else {
497
- scriptContent = `#!/bin/bash
498
- cd "${worktreePath}"
499
- while true; do
500
- ${baseCmd} "$(cat '${promptFile}')"
501
- echo ""
502
- echo "Claude exited. Restarting in 2 seconds... (Ctrl+C to quit)"
503
- sleep 2
504
- done
505
- `;
506
- }
507
- writeFileSync(scriptPath, scriptContent);
508
- chmodSync(scriptPath, '755');
509
- // Create PTY session via Tower REST API (shepherd for persistence)
510
- logger.info('Creating PTY terminal session...');
511
- const { terminalId } = await createPtySession(config, '/bin/bash', [scriptPath], worktreePath, { projectPath: config.projectRoot, type: 'builder', roleId: builderId });
512
- logger.info(`Terminal session created: ${terminalId}`);
513
- return { terminalId };
514
- }
515
- /**
516
- * Start a shell session (no worktree, just node-pty)
517
- */
518
- async function startShellSession(config, shellId, baseCmd) {
519
- // Create PTY session via REST API
520
- logger.info('Creating PTY terminal session for shell...');
521
- const { terminalId } = await createPtySession(config, '/bin/bash', ['-c', baseCmd], config.projectRoot, { projectPath: config.projectRoot, type: 'shell', roleId: shellId });
522
- logger.info(`Shell terminal session created: ${terminalId}`);
523
- return { terminalId };
524
- }
525
- /**
526
- * Pre-initialize porch in a worktree so the builder doesn't need to self-correct.
527
- * Non-fatal: logs a warning on failure since the builder can still init manually.
528
- */
529
- async function initPorchInWorktree(worktreePath, protocol, projectId, projectName) {
530
- logger.info('Initializing porch...');
531
- try {
532
- // Sanitize inputs to prevent shell injection (defense-in-depth;
533
- // callers already use slugified names, but be safe)
534
- const safeName = projectName.replace(/[^a-z0-9_-]/gi, '-');
535
- const safeProto = protocol.replace(/[^a-z0-9_-]/gi, '');
536
- const safeId = projectId.replace(/[^a-z0-9_-]/gi, '');
537
- await run(`porch init ${safeProto} ${safeId} "${safeName}"`, { cwd: worktreePath });
538
- logger.info(`Porch initialized: ${projectId}`);
539
- }
540
- catch (error) {
541
- logger.warn(`Warning: Failed to initialize porch (builder can init manually): ${error}`);
542
- }
543
- }
544
100
  // =============================================================================
545
101
  // Mode-specific spawn implementations
546
102
  // =============================================================================
@@ -568,23 +124,13 @@ async function spawnSpec(options, config) {
568
124
  await ensureDirectories(config);
569
125
  await checkDependencies();
570
126
  if (options.resume) {
571
- if (!existsSync(worktreePath)) {
572
- fatal(`Cannot resume: worktree does not exist at ${worktreePath}`);
573
- }
574
- if (!existsSync(resolve(worktreePath, '.git'))) {
575
- fatal(`Cannot resume: ${worktreePath} is not a valid git worktree`);
576
- }
577
- logger.info('Resuming existing worktree (skipping creation)');
127
+ validateResumeWorktree(worktreePath);
578
128
  }
579
129
  else {
580
130
  await createWorktree(config, branchName, worktreePath);
581
131
  }
582
- // Resolve protocol using precedence: --use-protocol > spec header > default
583
132
  const protocol = await resolveProtocol(options, config);
584
- const protocolPath = `codev/protocols/${protocol}/protocol.md`;
585
- // Load protocol definition for potential hooks/config
586
133
  const protocolDef = loadProtocol(config, protocol);
587
- // Resolve mode: --soft flag > protocol defaults > input type defaults
588
134
  const mode = resolveMode(options, protocolDef);
589
135
  logger.kv('Protocol', protocol.toUpperCase());
590
136
  logger.kv('Mode', mode.toUpperCase());
@@ -593,41 +139,27 @@ async function spawnSpec(options, config) {
593
139
  const porchProjectName = specName.replace(new RegExp(`^${projectId}-`), '');
594
140
  await initPorchInWorktree(worktreePath, protocol, projectId, porchProjectName);
595
141
  }
596
- // Build the prompt using template
597
142
  const specRelPath = `codev/specs/${specName}.md`;
598
143
  const planRelPath = `codev/plans/${specName}.md`;
599
144
  const templateContext = {
600
- protocol_name: protocol.toUpperCase(),
601
- mode,
602
- mode_soft: mode === 'soft',
603
- mode_strict: mode === 'strict',
145
+ protocol_name: protocol.toUpperCase(), mode,
146
+ mode_soft: mode === 'soft', mode_strict: mode === 'strict',
604
147
  project_id: projectId,
605
148
  input_description: `the feature specified in ${specRelPath}`,
606
149
  spec: { path: specRelPath, name: specName },
607
150
  };
608
- if (hasPlan) {
151
+ if (hasPlan)
609
152
  templateContext.plan = { path: planRelPath, name: specName };
610
- }
611
153
  const initialPrompt = buildPromptFromTemplate(config, protocol, templateContext);
612
154
  const resumeNotice = options.resume ? `\n${buildResumeNotice(projectId)}\n` : '';
613
- const builderPrompt = `You are a Builder. Read codev/roles/builder.md for your full role definition.
614
- ${resumeNotice}
615
- ${initialPrompt}`;
616
- // Load role
155
+ const builderPrompt = `You are a Builder. Read codev/roles/builder.md for your full role definition.\n${resumeNotice}\n${initialPrompt}`;
617
156
  const role = options.noRole ? null : loadRolePrompt(config, 'builder');
618
157
  const commands = getResolvedCommands();
619
158
  const { terminalId } = await startBuilderSession(config, builderId, worktreePath, commands.builder, builderPrompt, role?.content ?? null, role?.source ?? null);
620
- const builder = {
621
- id: builderId,
622
- name: specName,
623
- status: 'implementing',
624
- phase: 'init',
625
- worktree: worktreePath,
626
- branch: branchName,
627
- type: 'spec',
628
- terminalId,
629
- };
630
- upsertBuilder(builder);
159
+ upsertBuilder({
160
+ id: builderId, name: specName, status: 'implementing', phase: 'init',
161
+ worktree: worktreePath, branch: branchName, type: 'spec', terminalId,
162
+ });
631
163
  logger.blank();
632
164
  logger.success(`Builder ${builderId} spawned!`);
633
165
  logger.kv('Mode', mode === 'strict' ? 'Strict (porch-driven)' : 'Soft (protocol-guided)');
@@ -652,18 +184,11 @@ async function spawnTask(options, config) {
652
184
  await ensureDirectories(config);
653
185
  await checkDependencies();
654
186
  if (options.resume) {
655
- if (!existsSync(worktreePath)) {
656
- fatal(`Cannot resume: worktree does not exist at ${worktreePath}`);
657
- }
658
- if (!existsSync(resolve(worktreePath, '.git'))) {
659
- fatal(`Cannot resume: ${worktreePath} is not a valid git worktree`);
660
- }
661
- logger.info('Resuming existing worktree (skipping creation)');
187
+ validateResumeWorktree(worktreePath);
662
188
  }
663
189
  else {
664
190
  await createWorktree(config, branchName, worktreePath);
665
191
  }
666
- // Build the prompt — only include protocol if explicitly requested
667
192
  let taskDescription = taskText;
668
193
  if (options.files && options.files.length > 0) {
669
194
  taskDescription += `\n\nRelevant files to consider:\n${options.files.map(f => `- ${f}`).join('\n')}`;
@@ -676,40 +201,25 @@ async function spawnTask(options, config) {
676
201
  const protocolDef = loadProtocol(config, protocol);
677
202
  const mode = resolveMode(options, protocolDef);
678
203
  const templateContext = {
679
- protocol_name: protocol.toUpperCase(),
680
- mode,
681
- mode_soft: mode === 'soft',
682
- mode_strict: mode === 'strict',
683
- project_id: builderId,
684
- input_description: 'an ad-hoc task',
685
- task_text: taskDescription,
204
+ protocol_name: protocol.toUpperCase(), mode,
205
+ mode_soft: mode === 'soft', mode_strict: mode === 'strict',
206
+ project_id: builderId, input_description: 'an ad-hoc task', task_text: taskDescription,
686
207
  };
687
208
  const prompt = buildPromptFromTemplate(config, protocol, templateContext);
688
209
  builderPrompt = `You are a Builder. Read codev/roles/builder.md for your full role definition.\n${resumeNotice}\n${prompt}`;
689
210
  }
690
211
  else {
691
- builderPrompt = `You are a Builder. Read codev/roles/builder.md for your full role definition.
692
- ${resumeNotice}
693
- # Task
694
-
695
- ${taskDescription}`;
212
+ builderPrompt = `You are a Builder. Read codev/roles/builder.md for your full role definition.\n${resumeNotice}\n# Task\n\n${taskDescription}`;
696
213
  }
697
- // Load role
698
214
  const role = options.noRole ? null : loadRolePrompt(config, 'builder');
699
215
  const commands = getResolvedCommands();
700
216
  const { terminalId } = await startBuilderSession(config, builderId, worktreePath, commands.builder, builderPrompt, role?.content ?? null, role?.source ?? null);
701
- const builder = {
217
+ upsertBuilder({
702
218
  id: builderId,
703
219
  name: `Task: ${taskText.substring(0, 30)}${taskText.length > 30 ? '...' : ''}`,
704
- status: 'implementing',
705
- phase: 'init',
706
- worktree: worktreePath,
707
- branch: branchName,
708
- type: 'task',
709
- taskText,
710
- terminalId,
711
- };
712
- upsertBuilder(builder);
220
+ status: 'implementing', phase: 'init',
221
+ worktree: worktreePath, branch: branchName, type: 'task', taskText, terminalId,
222
+ });
713
223
  logger.blank();
714
224
  logger.success(`Builder ${builderId} spawned!`);
715
225
  logger.kv('Terminal', `ws://localhost:${DEFAULT_TOWER_PORT}/ws/terminal/${terminalId}`);
@@ -731,49 +241,31 @@ async function spawnProtocol(options, config) {
731
241
  await ensureDirectories(config);
732
242
  await checkDependencies();
733
243
  if (options.resume) {
734
- if (!existsSync(worktreePath)) {
735
- fatal(`Cannot resume: worktree does not exist at ${worktreePath}`);
736
- }
737
- if (!existsSync(resolve(worktreePath, '.git'))) {
738
- fatal(`Cannot resume: ${worktreePath} is not a valid git worktree`);
739
- }
740
- logger.info('Resuming existing worktree (skipping creation)');
244
+ validateResumeWorktree(worktreePath);
741
245
  }
742
246
  else {
743
247
  await createWorktree(config, branchName, worktreePath);
744
248
  }
745
- // Load protocol definition and resolve mode
746
249
  const protocolDef = loadProtocol(config, protocolName);
747
250
  const mode = resolveMode(options, protocolDef);
748
251
  logger.kv('Mode', mode.toUpperCase());
749
- // Build the prompt using template
750
252
  const templateContext = {
751
- protocol_name: protocolName.toUpperCase(),
752
- mode,
753
- mode_soft: mode === 'soft',
754
- mode_strict: mode === 'strict',
253
+ protocol_name: protocolName.toUpperCase(), mode,
254
+ mode_soft: mode === 'soft', mode_strict: mode === 'strict',
755
255
  project_id: builderId,
756
256
  input_description: `running the ${protocolName.toUpperCase()} protocol`,
757
257
  };
758
258
  const promptContent = buildPromptFromTemplate(config, protocolName, templateContext);
759
259
  const resumeNotice = options.resume ? `\n${buildResumeNotice(builderId)}\n` : '';
760
260
  const prompt = resumeNotice ? `${resumeNotice}\n${promptContent}` : promptContent;
761
- // Load protocol-specific role or fall back to builder role
762
261
  const role = options.noRole ? null : loadProtocolRole(config, protocolName);
763
262
  const commands = getResolvedCommands();
764
263
  const { terminalId } = await startBuilderSession(config, builderId, worktreePath, commands.builder, prompt, role?.content ?? null, role?.source ?? null);
765
- const builder = {
766
- id: builderId,
767
- name: `Protocol: ${protocolName}`,
768
- status: 'implementing',
769
- phase: 'init',
770
- worktree: worktreePath,
771
- branch: branchName,
772
- type: 'protocol',
773
- protocolName,
774
- terminalId,
775
- };
776
- upsertBuilder(builder);
264
+ upsertBuilder({
265
+ id: builderId, name: `Protocol: ${protocolName}`,
266
+ status: 'implementing', phase: 'init',
267
+ worktree: worktreePath, branch: branchName, type: 'protocol', protocolName, terminalId,
268
+ });
777
269
  logger.blank();
778
270
  logger.success(`Builder ${builderId} spawned!`);
779
271
  logger.kv('Terminal', `ws://localhost:${DEFAULT_TOWER_PORT}/ws/terminal/${terminalId}`);
@@ -789,19 +281,11 @@ async function spawnShell(options, config) {
789
281
  await checkDependencies();
790
282
  const commands = getResolvedCommands();
791
283
  const { terminalId } = await startShellSession(config, shortId, commands.builder);
792
- // Shell sessions are tracked as builders with type 'shell'
793
- // They don't have worktrees or branches
794
- const builder = {
795
- id: shellId,
796
- name: 'Shell session',
797
- status: 'implementing',
798
- phase: 'interactive',
799
- worktree: '',
800
- branch: '',
801
- type: 'shell',
802
- terminalId,
803
- };
804
- upsertBuilder(builder);
284
+ upsertBuilder({
285
+ id: shellId, name: 'Shell session',
286
+ status: 'implementing', phase: 'interactive',
287
+ worktree: '', branch: '', type: 'shell', terminalId,
288
+ });
805
289
  logger.blank();
806
290
  logger.success(`Shell ${shellId} spawned!`);
807
291
  logger.kv('Terminal', `ws://localhost:${DEFAULT_TOWER_PORT}/ws/terminal/${terminalId}`);
@@ -821,140 +305,30 @@ async function spawnWorktree(options, config) {
821
305
  await ensureDirectories(config);
822
306
  await checkDependencies();
823
307
  if (options.resume) {
824
- if (!existsSync(worktreePath)) {
825
- fatal(`Cannot resume: worktree does not exist at ${worktreePath}`);
826
- }
827
- if (!existsSync(resolve(worktreePath, '.git'))) {
828
- fatal(`Cannot resume: ${worktreePath} is not a valid git worktree`);
829
- }
830
- logger.info('Resuming existing worktree (skipping creation)');
308
+ validateResumeWorktree(worktreePath);
831
309
  }
832
310
  else {
833
311
  await createWorktree(config, branchName, worktreePath);
834
312
  }
835
- // Load builder role
836
313
  const role = options.noRole ? null : loadRolePrompt(config, 'builder');
837
314
  const commands = getResolvedCommands();
838
- // Worktree mode: launch Claude with no prompt, but in the worktree directory
839
315
  logger.info('Creating terminal session...');
840
- // Build launch script (with role if provided) to avoid shell escaping issues
316
+ const scriptContent = buildWorktreeLaunchScript(worktreePath, commands.builder, role);
841
317
  const scriptPath = resolve(worktreePath, '.builder-start.sh');
842
- let scriptContent;
843
- if (role) {
844
- const roleFile = resolve(worktreePath, '.builder-role.md');
845
- // Inject the actual dashboard port into the role prompt
846
- const roleWithPort = role.content.replace(/\{PORT\}/g, String(DEFAULT_TOWER_PORT));
847
- writeFileSync(roleFile, roleWithPort);
848
- logger.info(`Loaded role (${role.source})`);
849
- scriptContent = `#!/bin/bash
850
- cd "${worktreePath}"
851
- while true; do
852
- ${commands.builder} --append-system-prompt "$(cat '${roleFile}')"
853
- echo ""
854
- echo "Claude exited. Restarting in 2 seconds... (Ctrl+C to quit)"
855
- sleep 2
856
- done
857
- `;
858
- }
859
- else {
860
- scriptContent = `#!/bin/bash
861
- cd "${worktreePath}"
862
- while true; do
863
- ${commands.builder}
864
- echo ""
865
- echo "Claude exited. Restarting in 2 seconds... (Ctrl+C to quit)"
866
- sleep 2
867
- done
868
- `;
869
- }
870
318
  writeFileSync(scriptPath, scriptContent, { mode: 0o755 });
871
- // Create PTY session via REST API
872
319
  logger.info('Creating PTY terminal session for worktree...');
873
320
  const { terminalId: worktreeTerminalId } = await createPtySession(config, '/bin/bash', [scriptPath], worktreePath, { projectPath: config.projectRoot, type: 'builder', roleId: builderId });
874
321
  logger.info(`Worktree terminal session created: ${worktreeTerminalId}`);
875
- const builder = {
876
- id: builderId,
877
- name: 'Worktree session',
878
- status: 'implementing',
879
- phase: 'interactive',
880
- worktree: worktreePath,
881
- branch: branchName,
882
- type: 'worktree',
322
+ upsertBuilder({
323
+ id: builderId, name: 'Worktree session',
324
+ status: 'implementing', phase: 'interactive',
325
+ worktree: worktreePath, branch: branchName, type: 'worktree',
883
326
  terminalId: worktreeTerminalId,
884
- };
885
- upsertBuilder(builder);
327
+ });
886
328
  logger.blank();
887
329
  logger.success(`Worktree ${builderId} spawned!`);
888
330
  logger.kv('Terminal', `ws://localhost:${DEFAULT_TOWER_PORT}/ws/terminal/${worktreeTerminalId}`);
889
331
  }
890
- /**
891
- * Generate a slug from an issue title (max 30 chars, lowercase, alphanumeric + hyphens)
892
- */
893
- function slugify(title) {
894
- return title
895
- .toLowerCase()
896
- .replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens
897
- .replace(/-+/g, '-') // Collapse multiple hyphens
898
- .replace(/^-|-$/g, '') // Trim leading/trailing hyphens
899
- .slice(0, 30); // Max 30 chars
900
- }
901
- /**
902
- * Fetch a GitHub issue via gh CLI
903
- */
904
- async function fetchGitHubIssue(issueNumber) {
905
- try {
906
- const result = await run(`gh issue view ${issueNumber} --json title,body,state,comments`);
907
- return JSON.parse(result.stdout);
908
- }
909
- catch (error) {
910
- fatal(`Failed to fetch issue #${issueNumber}. Ensure 'gh' CLI is installed and authenticated.`);
911
- throw error; // TypeScript doesn't know fatal() never returns
912
- }
913
- }
914
- /**
915
- * Check for collision conditions before spawning bugfix
916
- */
917
- async function checkBugfixCollisions(issueNumber, worktreePath, issue, force) {
918
- // 1. Check if worktree already exists
919
- if (existsSync(worktreePath)) {
920
- fatal(`Worktree already exists at ${worktreePath}\nRun: af cleanup --issue ${issueNumber}`);
921
- }
922
- // 2. Check for recent "On it" comments (< 24h old)
923
- const onItComments = issue.comments.filter((c) => c.body.toLowerCase().includes('on it'));
924
- if (onItComments.length > 0) {
925
- const lastComment = onItComments[onItComments.length - 1];
926
- const age = Date.now() - new Date(lastComment.createdAt).getTime();
927
- const hoursAgo = Math.round(age / (1000 * 60 * 60));
928
- if (hoursAgo < 24) {
929
- if (!force) {
930
- fatal(`Issue #${issueNumber} has "On it" comment from ${hoursAgo}h ago (by @${lastComment.author.login}).\nSomeone may already be working on this. Use --force to override.`);
931
- }
932
- logger.warn(`Warning: "On it" comment from ${hoursAgo}h ago - proceeding with --force`);
933
- }
934
- else {
935
- logger.warn(`Warning: Stale "On it" comment (${hoursAgo}h ago). Proceeding.`);
936
- }
937
- }
938
- // 3. Check for open PRs referencing this issue
939
- try {
940
- const prResult = await run(`gh pr list --search "in:body #${issueNumber}" --json number,title --limit 5`);
941
- const openPRs = JSON.parse(prResult.stdout);
942
- if (openPRs.length > 0) {
943
- if (!force) {
944
- const prList = openPRs.map((pr) => ` - PR #${pr.number}: ${pr.title}`).join('\n');
945
- fatal(`Found ${openPRs.length} open PR(s) referencing issue #${issueNumber}:\n${prList}\nUse --force to proceed anyway.`);
946
- }
947
- logger.warn(`Warning: Found ${openPRs.length} open PR(s) referencing issue - proceeding with --force`);
948
- }
949
- }
950
- catch {
951
- // Non-fatal: continue if PR check fails
952
- }
953
- // 4. Warn if issue is already closed
954
- if (issue.state === 'CLOSED') {
955
- logger.warn(`Warning: Issue #${issueNumber} is already closed`);
956
- }
957
- }
958
332
  /**
959
333
  * Spawn builder for a GitHub issue (bugfix mode)
960
334
  */
@@ -968,18 +342,15 @@ async function spawnBugfix(options, config) {
968
342
  const builderId = `bugfix-${issueNumber}`;
969
343
  const branchName = `builder/bugfix-${issueNumber}-${slug}`;
970
344
  const worktreePath = resolve(config.buildersDir, builderId);
971
- // Resolve protocol (allows --use-protocol override)
972
345
  const protocol = await resolveProtocol(options, config);
973
346
  const protocolDef = loadProtocol(config, protocol);
974
- // Resolve mode: --soft flag > protocol defaults > input type defaults (bugfix defaults to soft)
975
347
  const mode = resolveMode(options, protocolDef);
976
348
  logger.kv('Title', issue.title);
977
349
  logger.kv('Branch', branchName);
978
350
  logger.kv('Worktree', worktreePath);
979
351
  logger.kv('Protocol', protocol.toUpperCase());
980
352
  logger.kv('Mode', mode.toUpperCase());
981
- // Execute pre-spawn hooks from protocol.json (collision check, issue comment)
982
- // Skip collision checks in resume mode — the worktree is expected to exist
353
+ // Execute pre-spawn hooks (skip in resume mode)
983
354
  if (!options.resume) {
984
355
  if (protocolDef?.hooks?.['pre-spawn']) {
985
356
  await executePreSpawnHooks(protocolDef, {
@@ -1007,52 +378,32 @@ async function spawnBugfix(options, config) {
1007
378
  await ensureDirectories(config);
1008
379
  await checkDependencies();
1009
380
  if (options.resume) {
1010
- if (!existsSync(worktreePath)) {
1011
- fatal(`Cannot resume: worktree does not exist at ${worktreePath}`);
1012
- }
1013
- if (!existsSync(resolve(worktreePath, '.git'))) {
1014
- fatal(`Cannot resume: ${worktreePath} is not a valid git worktree`);
1015
- }
1016
- logger.info('Resuming existing worktree (skipping creation)');
381
+ validateResumeWorktree(worktreePath);
1017
382
  }
1018
383
  else {
1019
384
  await createWorktree(config, branchName, worktreePath);
1020
385
  // Pre-initialize porch so the builder doesn't need to figure out project ID
1021
386
  await initPorchInWorktree(worktreePath, protocol, builderId, slug);
1022
387
  }
1023
- // Build the prompt using template
1024
388
  const templateContext = {
1025
- protocol_name: protocol.toUpperCase(),
1026
- mode,
1027
- mode_soft: mode === 'soft',
1028
- mode_strict: mode === 'strict',
389
+ protocol_name: protocol.toUpperCase(), mode,
390
+ mode_soft: mode === 'soft', mode_strict: mode === 'strict',
1029
391
  project_id: builderId,
1030
392
  input_description: `a fix for GitHub Issue #${issueNumber}`,
1031
- issue: {
1032
- number: issueNumber,
1033
- title: issue.title,
1034
- body: issue.body || '(No description provided)',
1035
- },
393
+ issue: { number: issueNumber, title: issue.title, body: issue.body || '(No description provided)' },
1036
394
  };
1037
395
  const prompt = buildPromptFromTemplate(config, protocol, templateContext);
1038
396
  const resumeNotice = options.resume ? `\n${buildResumeNotice(builderId)}\n` : '';
1039
397
  const builderPrompt = `You are a Builder. Read codev/roles/builder.md for your full role definition.\n${resumeNotice}\n${prompt}`;
1040
- // Load role
1041
398
  const role = options.noRole ? null : loadRolePrompt(config, 'builder');
1042
399
  const commands = getResolvedCommands();
1043
400
  const { terminalId } = await startBuilderSession(config, builderId, worktreePath, commands.builder, builderPrompt, role?.content ?? null, role?.source ?? null);
1044
- const builder = {
401
+ upsertBuilder({
1045
402
  id: builderId,
1046
403
  name: `Bugfix #${issueNumber}: ${issue.title.substring(0, 40)}${issue.title.length > 40 ? '...' : ''}`,
1047
- status: 'implementing',
1048
- phase: 'init',
1049
- worktree: worktreePath,
1050
- branch: branchName,
1051
- type: 'bugfix',
1052
- issueNumber,
1053
- terminalId,
1054
- };
1055
- upsertBuilder(builder);
404
+ status: 'implementing', phase: 'init',
405
+ worktree: worktreePath, branch: branchName, type: 'bugfix', issueNumber, terminalId,
406
+ });
1056
407
  logger.blank();
1057
408
  logger.success(`Bugfix builder for issue #${issueNumber} spawned!`);
1058
409
  logger.kv('Mode', mode === 'strict' ? 'Strict (porch-driven)' : 'Soft (protocol-guided)');
@@ -1095,25 +446,14 @@ export async function spawn(options) {
1095
446
  // Non-fatal - continue with spawn even if prune fails
1096
447
  }
1097
448
  const mode = getSpawnMode(options);
1098
- switch (mode) {
1099
- case 'spec':
1100
- await spawnSpec(options, config);
1101
- break;
1102
- case 'bugfix':
1103
- await spawnBugfix(options, config);
1104
- break;
1105
- case 'task':
1106
- await spawnTask(options, config);
1107
- break;
1108
- case 'protocol':
1109
- await spawnProtocol(options, config);
1110
- break;
1111
- case 'shell':
1112
- await spawnShell(options, config);
1113
- break;
1114
- case 'worktree':
1115
- await spawnWorktree(options, config);
1116
- break;
1117
- }
449
+ const handlers = {
450
+ spec: () => spawnSpec(options, config),
451
+ bugfix: () => spawnBugfix(options, config),
452
+ task: () => spawnTask(options, config),
453
+ protocol: () => spawnProtocol(options, config),
454
+ shell: () => spawnShell(options, config),
455
+ worktree: () => spawnWorktree(options, config),
456
+ };
457
+ await handlers[mode]();
1118
458
  }
1119
459
  //# sourceMappingURL=spawn.js.map