@cluesmith/codev 2.0.0-rc.72 → 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.
- package/dashboard/dist/assets/{index-C7FtNK6Y.css → index-4n9zpWLY.css} +1 -1
- package/dashboard/dist/assets/{index-CDAINZKT.js → index-CH_utkcW.js} +32 -27
- package/dashboard/dist/assets/index-CH_utkcW.js.map +1 -0
- package/dashboard/dist/index.html +2 -2
- package/dist/agent-farm/commands/spawn-roles.d.ts +80 -0
- package/dist/agent-farm/commands/spawn-roles.d.ts.map +1 -0
- package/dist/agent-farm/commands/spawn-roles.js +278 -0
- package/dist/agent-farm/commands/spawn-roles.js.map +1 -0
- package/dist/agent-farm/commands/spawn-worktree.d.ts +96 -0
- package/dist/agent-farm/commands/spawn-worktree.d.ts.map +1 -0
- package/dist/agent-farm/commands/spawn-worktree.js +305 -0
- package/dist/agent-farm/commands/spawn-worktree.js.map +1 -0
- package/dist/agent-farm/commands/spawn.d.ts +5 -1
- package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
- package/dist/agent-farm/commands/spawn.js +65 -725
- package/dist/agent-farm/commands/spawn.js.map +1 -1
- package/dist/agent-farm/servers/tower-instances.d.ts +82 -0
- package/dist/agent-farm/servers/tower-instances.d.ts.map +1 -0
- package/dist/agent-farm/servers/tower-instances.js +441 -0
- package/dist/agent-farm/servers/tower-instances.js.map +1 -0
- package/dist/agent-farm/servers/tower-routes.d.ts +34 -0
- package/dist/agent-farm/servers/tower-routes.d.ts.map +1 -0
- package/dist/agent-farm/servers/tower-routes.js +1445 -0
- package/dist/agent-farm/servers/tower-routes.js.map +1 -0
- package/dist/agent-farm/servers/tower-server.d.ts +5 -2
- package/dist/agent-farm/servers/tower-server.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-server.js +74 -2860
- package/dist/agent-farm/servers/tower-server.js.map +1 -1
- package/dist/agent-farm/servers/tower-terminals.d.ts +119 -0
- package/dist/agent-farm/servers/tower-terminals.d.ts.map +1 -0
- package/dist/agent-farm/servers/tower-terminals.js +629 -0
- package/dist/agent-farm/servers/tower-terminals.js.map +1 -0
- package/dist/agent-farm/servers/tower-tunnel.d.ts +34 -0
- package/dist/agent-farm/servers/tower-tunnel.d.ts.map +1 -0
- package/dist/agent-farm/servers/tower-tunnel.js +299 -0
- package/dist/agent-farm/servers/tower-tunnel.js.map +1 -0
- package/dist/agent-farm/servers/tower-types.d.ts +85 -0
- package/dist/agent-farm/servers/tower-types.d.ts.map +1 -0
- package/dist/agent-farm/servers/tower-types.js +6 -0
- package/dist/agent-farm/servers/tower-types.js.map +1 -0
- package/dist/agent-farm/servers/tower-utils.d.ts +51 -0
- package/dist/agent-farm/servers/tower-utils.d.ts.map +1 -0
- package/dist/agent-farm/servers/tower-utils.js +161 -0
- package/dist/agent-farm/servers/tower-utils.js.map +1 -0
- package/dist/agent-farm/servers/tower-websocket.d.ts +25 -0
- package/dist/agent-farm/servers/tower-websocket.d.ts.map +1 -0
- package/dist/agent-farm/servers/tower-websocket.js +171 -0
- package/dist/agent-farm/servers/tower-websocket.js.map +1 -0
- package/package.json +1 -1
- package/dashboard/dist/assets/index-CDAINZKT.js.map +0 -1
|
@@ -1,141 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Spawn command
|
|
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,
|
|
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
|
|
18
|
+
import { run } from '../utils/shell.js';
|
|
16
19
|
import { upsertBuilder } from '../state.js';
|
|
17
20
|
import { loadRolePrompt } from '../utils/roles.js';
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
621
|
-
id: builderId,
|
|
622
|
-
|
|
623
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
217
|
+
upsertBuilder({
|
|
702
218
|
id: builderId,
|
|
703
219
|
name: `Task: ${taskText.substring(0, 30)}${taskText.length > 30 ? '...' : ''}`,
|
|
704
|
-
status: 'implementing',
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
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
|
-
|
|
766
|
-
id: builderId,
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
876
|
-
id: builderId,
|
|
877
|
-
|
|
878
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
401
|
+
upsertBuilder({
|
|
1045
402
|
id: builderId,
|
|
1046
403
|
name: `Bugfix #${issueNumber}: ${issue.title.substring(0, 40)}${issue.title.length > 40 ? '...' : ''}`,
|
|
1047
|
-
status: 'implementing',
|
|
1048
|
-
|
|
1049
|
-
|
|
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
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|