twin-cli 0.2.1 → 0.3.1
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/README.md +8 -2
- package/bin/twin.js +14 -7
- package/package.json +1 -1
- package/src/build.js +196 -52
- package/src/generate.js +13 -7
- package/src/init.js +2 -1
- package/src/llm.js +12 -10
- package/src/plan.js +85 -38
package/README.md
CHANGED
|
@@ -139,8 +139,10 @@ Runs an autonomous build loop using Claude Code. Each iteration:
|
|
|
139
139
|
- Repeats until all stories are done or max iterations hit
|
|
140
140
|
|
|
141
141
|
```bash
|
|
142
|
-
twin build
|
|
143
|
-
twin build --
|
|
142
|
+
twin build # build the current stories (default: 3)
|
|
143
|
+
twin build --loop # build, plan, build — fully autonomous
|
|
144
|
+
twin build --loop --stories 20 # stop after 20 stories
|
|
145
|
+
twin build --loop --minutes 30 # stop after 30 minutes
|
|
144
146
|
```
|
|
145
147
|
|
|
146
148
|
Requires Claude Code installed and available in your PATH.
|
|
@@ -165,6 +167,10 @@ The twin encodes how you think. The project files encode what you are building.
|
|
|
165
167
|
|
|
166
168
|
`twin tweak` for natural language updates. `twin share` for publishing your taste publicly.
|
|
167
169
|
|
|
170
|
+
## Notes
|
|
171
|
+
|
|
172
|
+
**macOS users:** You may see a brief system popup the first time `twin build` spawns Claude Code. This is macOS verifying the process. It auto-dismisses. To prevent it, enable your terminal app under System Settings → Privacy & Security → Developer Tools.
|
|
173
|
+
|
|
168
174
|
## License
|
|
169
175
|
|
|
170
176
|
MIT
|
package/bin/twin.js
CHANGED
|
@@ -17,17 +17,24 @@ if (!command || command === 'init') {
|
|
|
17
17
|
} else if (command === 'plan') {
|
|
18
18
|
plan();
|
|
19
19
|
} else if (command === 'build') {
|
|
20
|
-
const
|
|
21
|
-
|
|
20
|
+
const loop = process.argv.includes('--loop');
|
|
21
|
+
const storiesFlag = parseFlag('--stories', null);
|
|
22
|
+
const minutesFlag = parseFlag('--minutes', null);
|
|
23
|
+
const maxStories = storiesFlag ? parseInt(storiesFlag, 10) : (loop ? Infinity : 3);
|
|
24
|
+
const maxMinutes = minutesFlag ? parseInt(minutesFlag, 10) : null;
|
|
25
|
+
build({ maxStories, loop, maxMinutes });
|
|
22
26
|
} else if (command === '--help' || command === '-h') {
|
|
23
27
|
console.log(`
|
|
24
|
-
twin -
|
|
28
|
+
twin - your twin builds while you sleep
|
|
25
29
|
|
|
26
30
|
Usage:
|
|
27
|
-
twin init
|
|
28
|
-
twin plan
|
|
29
|
-
twin build [--
|
|
30
|
-
twin --
|
|
31
|
+
twin init Interview yourself, generate your .twin file
|
|
32
|
+
twin plan Your twin decides what to build next
|
|
33
|
+
twin build [--stories N] Build N stories using Claude Code (default: 3)
|
|
34
|
+
twin build --loop Build, plan, build — fully autonomous
|
|
35
|
+
twin build --loop --stories 20 Stop after 20 stories
|
|
36
|
+
twin build --loop --minutes 30 Stop after 30 minutes
|
|
37
|
+
twin --help Show this message
|
|
31
38
|
`);
|
|
32
39
|
} else {
|
|
33
40
|
console.log(`Unknown command: ${command}`);
|
package/package.json
CHANGED
package/src/build.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { readFile, writeFile, readdir } from 'node:fs/promises';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
|
+
import { runPlan } from './plan.js';
|
|
4
5
|
|
|
5
6
|
const COMPLETION_SIGNAL = '<twin>STORY_COMPLETE</twin>';
|
|
6
7
|
const ALL_DONE_SIGNAL = '<twin>ALL_COMPLETE</twin>';
|
|
@@ -17,7 +18,7 @@ async function findTwinFile(cwd) {
|
|
|
17
18
|
const files = await readdir(cwd);
|
|
18
19
|
const twinFiles = files.filter((f) => f.endsWith('.twin'));
|
|
19
20
|
if (twinFiles.length === 0) {
|
|
20
|
-
console.error('No .twin file found. Run `twin init` first.\n');
|
|
21
|
+
console.error('No .twin file found. Run `npx twin-cli init` first.\n');
|
|
21
22
|
process.exit(1);
|
|
22
23
|
}
|
|
23
24
|
return resolve(cwd, twinFiles[0]);
|
|
@@ -29,22 +30,23 @@ function buildPrompt(twinContent, twinFilename, prdContent, progressContent) {
|
|
|
29
30
|
1. **The twin file** (${twinFilename}) — this is the builder's taste, their decision-making DNA. Build the way they would build.
|
|
30
31
|
2. **prd.json** — the product requirements with user stories. Each story has a status: "open", "in_progress", or "done".
|
|
31
32
|
|
|
32
|
-
${progressContent ? '3. **progress.md** — notes from previous build
|
|
33
|
+
${progressContent ? '3. **progress.md** — notes from previous build runs. Read this to understand what was already tried and learned.\n' : ''}
|
|
33
34
|
## Your task
|
|
34
35
|
|
|
35
36
|
1. Read prd.json and find stories that are not "done"
|
|
36
|
-
2. Pick the story YOU think should be built next based on the twin's taste and
|
|
37
|
-
3. Build
|
|
37
|
+
2. Pick ONE story — the single story YOU think should be built next based on the twin's taste and the current state of the codebase
|
|
38
|
+
3. Build that ONE story. Write real, working code. Follow the acceptance criteria.
|
|
38
39
|
4. When the story's acceptance criteria are met, update prd.json: set that story's status to "done" and add "completedAt" with the current ISO timestamp
|
|
39
|
-
5. Append to progress.md what you built, what files changed, and any learnings
|
|
40
|
-
6.
|
|
41
|
-
7.
|
|
40
|
+
5. Append to progress.md what you built, what files changed, and any learnings
|
|
41
|
+
6. Commit your work with a clear message
|
|
42
|
+
7. Output ${COMPLETION_SIGNAL} when you finish the story
|
|
43
|
+
8. If ALL stories in prd.json are "done" after completing yours, output ${ALL_DONE_SIGNAL} instead
|
|
42
44
|
|
|
43
45
|
## Rules
|
|
46
|
+
- Build ONE story per run. Do not start a second story.
|
|
44
47
|
- Build real features, not stubs
|
|
45
48
|
- Follow the taste in the twin file — it tells you how this person builds
|
|
46
|
-
-
|
|
47
|
-
- If you get stuck, note what blocked you in progress.md and move on to a different story
|
|
49
|
+
- If you get stuck, note what blocked you in progress.md and output ${COMPLETION_SIGNAL} anyway
|
|
48
50
|
- Do NOT ask questions. Make decisions based on the twin and the PRD.
|
|
49
51
|
|
|
50
52
|
## Twin file (${twinFilename})
|
|
@@ -106,6 +108,7 @@ function runIteration(prompt, cwd) {
|
|
|
106
108
|
let buffer = '';
|
|
107
109
|
let lastActivity = Date.now();
|
|
108
110
|
let statusLine = false; // true when a status line is showing
|
|
111
|
+
let atLineStart = true; // track if cursor is at start of a line
|
|
109
112
|
|
|
110
113
|
const TOOL_LABELS = {
|
|
111
114
|
Read: 'Reading file',
|
|
@@ -124,6 +127,10 @@ function runIteration(prompt, cwd) {
|
|
|
124
127
|
}
|
|
125
128
|
|
|
126
129
|
function showStatus(msg) {
|
|
130
|
+
if (!atLineStart && !statusLine) {
|
|
131
|
+
process.stdout.write('\n');
|
|
132
|
+
atLineStart = true;
|
|
133
|
+
}
|
|
127
134
|
clearStatus();
|
|
128
135
|
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
129
136
|
process.stdout.write(`\r\x1b[2m${msg} (${elapsed}s)\x1b[0m`);
|
|
@@ -154,8 +161,15 @@ function runIteration(prompt, cwd) {
|
|
|
154
161
|
|
|
155
162
|
if (event.type === 'text') {
|
|
156
163
|
clearStatus();
|
|
157
|
-
process.stdout.write(event.text);
|
|
158
164
|
output += event.text;
|
|
165
|
+
// Hide completion signals from user — they're internal plumbing
|
|
166
|
+
const display = event.text
|
|
167
|
+
.replace(COMPLETION_SIGNAL, '')
|
|
168
|
+
.replace(ALL_DONE_SIGNAL, '');
|
|
169
|
+
if (display) {
|
|
170
|
+
process.stdout.write(display);
|
|
171
|
+
atLineStart = display.endsWith('\n');
|
|
172
|
+
}
|
|
159
173
|
} else if (event.type === 'tool') {
|
|
160
174
|
const label = TOOL_LABELS[event.name] || event.name;
|
|
161
175
|
showStatus(label);
|
|
@@ -199,78 +213,208 @@ function runIteration(prompt, cwd) {
|
|
|
199
213
|
});
|
|
200
214
|
}
|
|
201
215
|
|
|
202
|
-
|
|
216
|
+
// Dim helper for secondary/chrome text
|
|
217
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
218
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
219
|
+
const bar = dim('─'.repeat(60));
|
|
220
|
+
|
|
221
|
+
const STORY_RETRIES = 2;
|
|
222
|
+
const STORY_RETRY_DELAYS = [10_000, 30_000]; // 10s, then 30s
|
|
223
|
+
|
|
224
|
+
function sleep(ms) {
|
|
225
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function summary(totalBuilt, cycles, elapsed) {
|
|
229
|
+
const mins = Math.round(elapsed / 60000);
|
|
230
|
+
const time = mins > 0 ? ` in ${mins}m` : '';
|
|
231
|
+
const cycleNote = cycles > 1 ? ` across ${cycles} cycles` : '';
|
|
232
|
+
return `${totalBuilt} stories built${cycleNote}${time}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function build({ maxStories = 3, loop = false, maxMinutes = null } = {}) {
|
|
203
236
|
const cwd = process.cwd();
|
|
237
|
+
const startTime = Date.now();
|
|
238
|
+
const timeLimitMs = maxMinutes ? maxMinutes * 60 * 1000 : null;
|
|
239
|
+
|
|
240
|
+
// Clean exit on Ctrl+C — finish current story, then stop
|
|
241
|
+
let stopping = false;
|
|
242
|
+
process.on('SIGINT', () => {
|
|
243
|
+
if (stopping) process.exit(1); // Second Ctrl+C forces exit
|
|
244
|
+
stopping = true;
|
|
245
|
+
console.log(dim('\n\nStopping after current story finishes...\n'));
|
|
246
|
+
});
|
|
204
247
|
|
|
205
248
|
// Find twin file
|
|
206
249
|
const twinPath = await findTwinFile(cwd);
|
|
207
250
|
const twinFilename = twinPath.split('/').pop();
|
|
208
|
-
const twinContent = await readFile(twinPath, 'utf-8');
|
|
209
251
|
|
|
210
252
|
// Read prd.json — required
|
|
211
253
|
const prdPath = resolve(cwd, 'prd.json');
|
|
212
254
|
const prdContent = await readIfExists(prdPath);
|
|
213
255
|
if (!prdContent) {
|
|
214
|
-
console.error('No prd.json found. Run `twin plan` first.\n');
|
|
256
|
+
console.error('No prd.json found. Run `npx twin-cli plan` first.\n');
|
|
215
257
|
process.exit(1);
|
|
216
258
|
}
|
|
217
259
|
|
|
218
260
|
// Check if there are open stories
|
|
219
261
|
const prd = JSON.parse(prdContent);
|
|
220
262
|
const openStories = prd.userStories.filter((s) => s.status !== 'done');
|
|
221
|
-
if (openStories.length === 0) {
|
|
222
|
-
console.log('All stories
|
|
263
|
+
if (openStories.length === 0 && !loop) {
|
|
264
|
+
console.log('All stories are done. Plan the next batch:\n npx twin-cli plan\n');
|
|
223
265
|
process.exit(0);
|
|
224
266
|
}
|
|
267
|
+
// In loop mode with no open stories, the while loop will trigger planning
|
|
268
|
+
|
|
269
|
+
console.log('');
|
|
270
|
+
console.log(bar);
|
|
271
|
+
console.log(bold(` twin build${loop ? ' --loop' : ''}`));
|
|
272
|
+
console.log(dim(` ${twinFilename}`));
|
|
273
|
+
if (loop) {
|
|
274
|
+
const limits = [];
|
|
275
|
+
if (maxStories !== Infinity) limits.push(`${maxStories} stories`);
|
|
276
|
+
if (maxMinutes) limits.push(`${maxMinutes} min`);
|
|
277
|
+
console.log(dim(` ${limits.length > 0 ? limits.join(' · ') : 'no limit — Ctrl+C to stop'}`));
|
|
278
|
+
} else {
|
|
279
|
+
const storiesThisRun = Math.min(maxStories, openStories.length);
|
|
280
|
+
console.log(dim(` ${openStories.length} remaining · building ${storiesThisRun}`));
|
|
281
|
+
}
|
|
282
|
+
console.log(bar);
|
|
283
|
+
console.log('');
|
|
284
|
+
|
|
285
|
+
let totalBuilt = 0;
|
|
286
|
+
let cycle = 1;
|
|
287
|
+
|
|
288
|
+
while (totalBuilt < maxStories) {
|
|
289
|
+
// Check if user requested stop
|
|
290
|
+
if (stopping) {
|
|
291
|
+
console.log(bar);
|
|
292
|
+
console.log(bold(' Stopped'));
|
|
293
|
+
console.log(dim(` ${summary(totalBuilt, cycle, Date.now() - startTime)}`));
|
|
294
|
+
console.log(bar);
|
|
295
|
+
console.log('');
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
225
298
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
299
|
+
// Re-read twin each cycle (user may tweak mid-run)
|
|
300
|
+
const twinContent = await readFile(twinPath, 'utf-8');
|
|
301
|
+
|
|
302
|
+
// Re-read prd.json (previous story or plan cycle may have updated it)
|
|
303
|
+
const currentPrdContent = await readFile(prdPath, 'utf-8');
|
|
304
|
+
const currentPrd = JSON.parse(currentPrdContent);
|
|
305
|
+
const remaining = currentPrd.userStories.filter((s) => s.status !== 'done');
|
|
306
|
+
|
|
307
|
+
// No open stories — either plan more or exit
|
|
308
|
+
if (remaining.length === 0) {
|
|
309
|
+
if (!loop) {
|
|
310
|
+
console.log('');
|
|
311
|
+
console.log(bar);
|
|
312
|
+
console.log(bold(' All stories complete'));
|
|
313
|
+
console.log(dim(` ${summary(totalBuilt, cycle, Date.now() - startTime)}`));
|
|
314
|
+
console.log(bar);
|
|
315
|
+
console.log('\nNext step — plan more features:');
|
|
316
|
+
console.log(' npx twin-cli plan\n');
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
239
319
|
|
|
240
|
-
|
|
241
|
-
|
|
320
|
+
// Loop mode — ask the twin to plan the next batch
|
|
321
|
+
console.log('');
|
|
322
|
+
console.log(bar);
|
|
323
|
+
console.log(bold(` Cycle ${cycle} complete`));
|
|
324
|
+
console.log(dim(' Your twin is planning the next batch...'));
|
|
325
|
+
console.log(bar);
|
|
326
|
+
console.log('');
|
|
327
|
+
|
|
328
|
+
const newStories = await runPlan(cwd);
|
|
329
|
+
|
|
330
|
+
if (newStories.length === 0) {
|
|
331
|
+
console.log(bar);
|
|
332
|
+
console.log(bold(' Your twin has built everything it would build right now.'));
|
|
333
|
+
console.log(dim(` ${summary(totalBuilt, cycle, Date.now() - startTime)}`));
|
|
334
|
+
console.log(bar);
|
|
335
|
+
console.log('');
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
242
338
|
|
|
243
|
-
|
|
244
|
-
|
|
339
|
+
console.log(`Planned ${newStories.length} new stories:`);
|
|
340
|
+
for (const story of newStories) {
|
|
341
|
+
console.log(dim(` ${story.id}`) + ` ${story.title}`);
|
|
342
|
+
}
|
|
343
|
+
console.log('');
|
|
344
|
+
cycle++;
|
|
345
|
+
continue; // Back to top of while loop to build them
|
|
245
346
|
}
|
|
246
347
|
|
|
247
|
-
// Check
|
|
248
|
-
if (
|
|
249
|
-
|
|
250
|
-
console.log(
|
|
251
|
-
console.log(
|
|
252
|
-
console.log(
|
|
253
|
-
console.log(
|
|
348
|
+
// Check time limit before starting next story
|
|
349
|
+
if (timeLimitMs && (Date.now() - startTime) >= timeLimitMs) {
|
|
350
|
+
const mins = Math.round((Date.now() - startTime) / 60000);
|
|
351
|
+
console.log(bar);
|
|
352
|
+
console.log(bold(' Time limit reached'));
|
|
353
|
+
console.log(dim(` ${summary(totalBuilt, cycle, Date.now() - startTime)}`));
|
|
354
|
+
console.log(bar);
|
|
355
|
+
console.log('');
|
|
254
356
|
break;
|
|
255
357
|
}
|
|
256
358
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
359
|
+
// Build one story
|
|
360
|
+
const storyNum = totalBuilt + 1;
|
|
361
|
+
|
|
362
|
+
console.log('');
|
|
363
|
+
console.log(bar);
|
|
364
|
+
if (maxStories === Infinity) {
|
|
365
|
+
console.log(bold(` Story ${storyNum}`));
|
|
366
|
+
} else {
|
|
367
|
+
console.log(bold(` Story ${storyNum} of ${maxStories}`));
|
|
368
|
+
}
|
|
369
|
+
console.log(bar);
|
|
370
|
+
console.log('');
|
|
371
|
+
|
|
372
|
+
const progressContent = await readIfExists(resolve(cwd, 'progress.md'));
|
|
373
|
+
const prompt = buildPrompt(twinContent, twinFilename, currentPrdContent, progressContent);
|
|
374
|
+
|
|
375
|
+
let succeeded = false;
|
|
376
|
+
for (let attempt = 0; attempt <= STORY_RETRIES; attempt++) {
|
|
377
|
+
if (attempt > 0) {
|
|
378
|
+
const delay = STORY_RETRY_DELAYS[attempt - 1];
|
|
379
|
+
console.log(dim(`\n API error — retrying in ${delay / 1000}s...\n`));
|
|
380
|
+
await sleep(delay);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const { output, code } = await runIteration(prompt, cwd);
|
|
384
|
+
|
|
385
|
+
if (code !== 0) {
|
|
386
|
+
if (attempt < STORY_RETRIES) continue; // retry
|
|
387
|
+
console.log(dim(`\nClaude exited with code ${code} after ${STORY_RETRIES + 1} attempts. Skipping story.\n`));
|
|
388
|
+
// Log the failure to progress.md so the next run knows
|
|
389
|
+
const progressPath = resolve(cwd, 'progress.md');
|
|
390
|
+
const existing = await readIfExists(progressPath) || '';
|
|
391
|
+
const note = `\n## Skipped story (${new Date().toISOString()})\nClaude exited with code ${code} after ${STORY_RETRIES + 1} attempts. Story was not counted.\n`;
|
|
392
|
+
await writeFile(progressPath, existing + note, 'utf-8');
|
|
267
393
|
break;
|
|
268
394
|
}
|
|
269
|
-
|
|
395
|
+
|
|
396
|
+
succeeded = true;
|
|
397
|
+
if (output.includes(COMPLETION_SIGNAL) || output.includes(ALL_DONE_SIGNAL)) {
|
|
398
|
+
const afterPrd = JSON.parse(await readFile(prdPath, 'utf-8'));
|
|
399
|
+
const left = afterPrd.userStories.filter((s) => s.status !== 'done');
|
|
400
|
+
if (left.length > 0) {
|
|
401
|
+
console.log(dim(`\nStory done. ${left.length} open in prd.json.\n`));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
break;
|
|
270
405
|
}
|
|
271
406
|
|
|
272
|
-
if (
|
|
273
|
-
|
|
407
|
+
if (succeeded) totalBuilt++;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (totalBuilt > 0 && totalBuilt >= maxStories) {
|
|
411
|
+
console.log(bar);
|
|
412
|
+
console.log(bold(` Done`));
|
|
413
|
+
console.log(dim(` ${summary(totalBuilt, cycle, Date.now() - startTime)}`));
|
|
414
|
+
console.log(bar);
|
|
415
|
+
if (!loop) {
|
|
416
|
+
console.log('\nKeep going:\n npx twin-cli build');
|
|
274
417
|
}
|
|
418
|
+
console.log('');
|
|
275
419
|
}
|
|
276
420
|
}
|
package/src/generate.js
CHANGED
|
@@ -43,15 +43,21 @@ Rules:
|
|
|
43
43
|
- No preamble, no explanation — just the .twin file content`;
|
|
44
44
|
|
|
45
45
|
export async function generateTwin(name, interviewText) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
let content;
|
|
47
|
+
try {
|
|
48
|
+
content = await callLLM(
|
|
49
|
+
SYSTEM_PROMPT,
|
|
50
|
+
`The builder's name is ${name}.\n\nHere are the interview answers. Generate the .twin file.\n\n${interviewText}`,
|
|
51
|
+
);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.error(`\n${err.message}\n`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
50
56
|
|
|
51
57
|
const filename = `${name.toLowerCase().replace(/[^a-z0-9]/g, '')}.twin`;
|
|
52
58
|
const outPath = resolve(process.cwd(), filename);
|
|
53
59
|
await writeFile(outPath, content, 'utf-8');
|
|
54
|
-
console.log(`Done!
|
|
55
|
-
console.log('
|
|
56
|
-
console.log(' twin plan');
|
|
60
|
+
console.log(`Done! Created ${filename}\n`);
|
|
61
|
+
console.log('Next step — plan what to build:');
|
|
62
|
+
console.log(' npx twin-cli plan');
|
|
57
63
|
}
|
package/src/init.js
CHANGED
|
@@ -25,7 +25,8 @@ export async function init() {
|
|
|
25
25
|
}
|
|
26
26
|
prompter.close();
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
const filename = `${name.toLowerCase().replace(/[^a-z0-9]/g, '')}.twin`;
|
|
29
|
+
console.log(`\nGenerating ${filename}...\n`);
|
|
29
30
|
|
|
30
31
|
const paired = answers.map((a) => `Q: ${a.question}\nA: ${a.answer}`).join('\n\n');
|
|
31
32
|
await generateTwin(name, paired);
|
package/src/llm.js
CHANGED
|
@@ -22,21 +22,24 @@ export async function callLLM(systemPrompt, userMessage) {
|
|
|
22
22
|
|
|
23
23
|
claude.on('close', (code) => {
|
|
24
24
|
if (code !== 0) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
const msg = stderr
|
|
26
|
+
? `Claude Code exited with code ${code}: ${stderr.trim()}`
|
|
27
|
+
: `Claude Code exited with code ${code}.`;
|
|
28
|
+
reject(new Error(msg));
|
|
29
|
+
return;
|
|
28
30
|
}
|
|
29
31
|
resolve(stdout.trim());
|
|
30
32
|
});
|
|
31
33
|
|
|
32
34
|
claude.on('error', (err) => {
|
|
33
35
|
if (err.code === 'ENOENT') {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
reject(new Error(
|
|
37
|
+
'Claude Code is not installed or not in PATH.\n'
|
|
38
|
+
+ 'Install it: https://docs.anthropic.com/en/docs/claude-code'
|
|
39
|
+
));
|
|
40
|
+
return;
|
|
37
41
|
}
|
|
38
|
-
|
|
39
|
-
process.exit(1);
|
|
42
|
+
reject(new Error(`Failed to spawn Claude: ${err.message}`));
|
|
40
43
|
});
|
|
41
44
|
|
|
42
45
|
claude.stdin.write(prompt);
|
|
@@ -44,8 +47,7 @@ export async function callLLM(systemPrompt, userMessage) {
|
|
|
44
47
|
});
|
|
45
48
|
|
|
46
49
|
if (!output) {
|
|
47
|
-
|
|
48
|
-
process.exit(1);
|
|
50
|
+
throw new Error('No content returned from Claude Code.');
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
return output;
|
package/src/plan.js
CHANGED
|
@@ -62,6 +62,13 @@ async function bootstrapProduct() {
|
|
|
62
62
|
return content;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
const PLAN_RETRIES = 2;
|
|
66
|
+
const PLAN_RETRY_DELAY = 15_000; // 15s
|
|
67
|
+
|
|
68
|
+
function sleep(ms) {
|
|
69
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
70
|
+
}
|
|
71
|
+
|
|
65
72
|
function parseLLMJson(raw) {
|
|
66
73
|
// Strip markdown code fences if the LLM wraps its response
|
|
67
74
|
let cleaned = raw.trim();
|
|
@@ -71,36 +78,28 @@ function parseLLMJson(raw) {
|
|
|
71
78
|
return JSON.parse(cleaned);
|
|
72
79
|
}
|
|
73
80
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Headless plan — callable from the build loop.
|
|
83
|
+
* Requires product.md and a .twin file to already exist.
|
|
84
|
+
* Returns the array of NEW stories added (empty if the twin has nothing to add).
|
|
85
|
+
*/
|
|
86
|
+
export async function runPlan(cwd) {
|
|
77
87
|
const files = await readdir(cwd);
|
|
78
88
|
const twinFiles = files.filter((f) => f.endsWith('.twin'));
|
|
79
|
-
if (twinFiles.length === 0)
|
|
80
|
-
|
|
81
|
-
process.exit(1);
|
|
82
|
-
}
|
|
83
|
-
if (twinFiles.length > 1) {
|
|
84
|
-
console.log(`Found multiple .twin files: ${twinFiles.join(', ')}. Using ${twinFiles[0]}.`);
|
|
85
|
-
}
|
|
89
|
+
if (twinFiles.length === 0) return [];
|
|
90
|
+
|
|
86
91
|
const twinPath = resolve(cwd, twinFiles[0]);
|
|
87
92
|
const twin = await readFile(twinPath, 'utf-8');
|
|
88
|
-
console.log(`Using ${twinFiles[0]}\n`);
|
|
89
93
|
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
if (!product) {
|
|
94
|
-
product = await bootstrapProduct();
|
|
95
|
-
}
|
|
94
|
+
const productPath = resolve(cwd, 'product.md');
|
|
95
|
+
const product = await readIfExists(productPath);
|
|
96
|
+
if (!product) return []; // Can't plan without product context
|
|
96
97
|
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
const prdPath = resolve(process.cwd(), 'prd.json');
|
|
98
|
+
const statusPath = resolve(cwd, 'status.md');
|
|
99
|
+
const prdPath = resolve(cwd, 'prd.json');
|
|
100
100
|
const status = await readIfExists(statusPath);
|
|
101
101
|
const existingPrd = await readIfExists(prdPath);
|
|
102
102
|
|
|
103
|
-
// Assemble user message
|
|
104
103
|
let userMessage = `## .twin file\n${twin}\n\n## product.md\n${product}`;
|
|
105
104
|
if (status) {
|
|
106
105
|
userMessage += `\n\n## status.md\n${status}`;
|
|
@@ -110,32 +109,41 @@ export async function plan() {
|
|
|
110
109
|
}
|
|
111
110
|
userMessage += '\n\nGenerate the next 3-5 capabilities as JSON.';
|
|
112
111
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
112
|
+
let raw;
|
|
113
|
+
for (let attempt = 0; attempt <= PLAN_RETRIES; attempt++) {
|
|
114
|
+
try {
|
|
115
|
+
raw = await callLLM(TASK_SYSTEM_PROMPT, userMessage);
|
|
116
|
+
break;
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (attempt < PLAN_RETRIES) {
|
|
119
|
+
console.log(`\x1b[2m API error — retrying plan in ${PLAN_RETRY_DELAY / 1000}s...\x1b[0m`);
|
|
120
|
+
await sleep(PLAN_RETRY_DELAY);
|
|
121
|
+
} else {
|
|
122
|
+
console.error(`Planning failed after ${PLAN_RETRIES + 1} attempts: ${err.message}`);
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
117
127
|
|
|
118
|
-
// Parse structured output
|
|
119
128
|
let prd;
|
|
120
129
|
try {
|
|
121
130
|
prd = parseLLMJson(raw);
|
|
122
|
-
} catch
|
|
123
|
-
|
|
124
|
-
console.error(raw);
|
|
125
|
-
process.exit(1);
|
|
131
|
+
} catch {
|
|
132
|
+
return [];
|
|
126
133
|
}
|
|
127
134
|
|
|
128
|
-
// Derive project name from cwd if LLM didn't provide one
|
|
129
135
|
if (!prd.project) {
|
|
130
|
-
prd.project = basename(
|
|
136
|
+
prd.project = basename(cwd);
|
|
131
137
|
}
|
|
132
138
|
|
|
133
|
-
|
|
139
|
+
const newStories = prd.userStories || [];
|
|
140
|
+
|
|
141
|
+
// Merge with existing stories
|
|
134
142
|
if (existingPrd) {
|
|
135
143
|
try {
|
|
136
144
|
const existing = JSON.parse(existingPrd);
|
|
137
145
|
const existingStories = existing.userStories || [];
|
|
138
|
-
prd.userStories = [...existingStories, ...
|
|
146
|
+
prd.userStories = [...existingStories, ...newStories];
|
|
139
147
|
if (!prd.project && existing.project) prd.project = existing.project;
|
|
140
148
|
if (!prd.description && existing.description) prd.description = existing.description;
|
|
141
149
|
} catch {
|
|
@@ -143,11 +151,47 @@ export async function plan() {
|
|
|
143
151
|
}
|
|
144
152
|
}
|
|
145
153
|
|
|
146
|
-
// Write prd.json
|
|
147
154
|
await writeFile(prdPath, JSON.stringify(prd, null, 2) + '\n', 'utf-8');
|
|
155
|
+
return newStories;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Interactive plan — called from `twin plan` CLI command.
|
|
160
|
+
* Bootstraps product.md if missing, prints stories to console.
|
|
161
|
+
*/
|
|
162
|
+
export async function plan() {
|
|
163
|
+
// Find *.twin file — required
|
|
164
|
+
const cwd = process.cwd();
|
|
165
|
+
const files = await readdir(cwd);
|
|
166
|
+
const twinFiles = files.filter((f) => f.endsWith('.twin'));
|
|
167
|
+
if (twinFiles.length === 0) {
|
|
168
|
+
console.error('No .twin file found. Run `npx twin-cli init` first.\n');
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
if (twinFiles.length > 1) {
|
|
172
|
+
console.log(`Found multiple .twin files: ${twinFiles.join(', ')}. Using ${twinFiles[0]}.`);
|
|
173
|
+
}
|
|
174
|
+
console.log(`Using ${twinFiles[0]}\n`);
|
|
175
|
+
|
|
176
|
+
// Read or bootstrap product.md
|
|
177
|
+
const productPath = resolve(cwd, 'product.md');
|
|
178
|
+
let product = await readIfExists(productPath);
|
|
179
|
+
if (!product) {
|
|
180
|
+
product = await bootstrapProduct();
|
|
181
|
+
}
|
|
148
182
|
|
|
149
|
-
|
|
150
|
-
|
|
183
|
+
console.log('--- twin plan ---');
|
|
184
|
+
console.log('Your twin is deciding what to build next...\n');
|
|
185
|
+
|
|
186
|
+
const newStories = await runPlan(cwd);
|
|
187
|
+
|
|
188
|
+
if (newStories.length === 0) {
|
|
189
|
+
console.log('Your twin has nothing to add right now.\n');
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Print new stories to console
|
|
194
|
+
for (const story of newStories) {
|
|
151
195
|
console.log(`${story.id}. ${story.title}`);
|
|
152
196
|
console.log(` ${story.description}`);
|
|
153
197
|
for (const ac of story.acceptanceCriteria) {
|
|
@@ -155,7 +199,10 @@ export async function plan() {
|
|
|
155
199
|
}
|
|
156
200
|
console.log('');
|
|
157
201
|
}
|
|
202
|
+
|
|
203
|
+
const prdPath = resolve(cwd, 'prd.json');
|
|
158
204
|
console.log(`---`);
|
|
159
205
|
console.log(`Wrote ${prdPath}`);
|
|
160
|
-
console.log(`\
|
|
206
|
+
console.log(`\nNext step — let your twin build it:`);
|
|
207
|
+
console.log(` npx twin-cli build`);
|
|
161
208
|
}
|