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 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 # default: 5 iterations
143
- twin build --max-iterations 10 # custom limit
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 maxIterations = parseInt(parseFlag('--max-iterations', '5'), 10);
21
- build(maxIterations);
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 - encode your decision-making DNA
28
+ twin - your twin builds while you sleep
25
29
 
26
30
  Usage:
27
- twin init Interview yourself, generate your .twin file
28
- twin plan Generate tasks that match your taste
29
- twin build [--max-iterations N] Build autonomously using Claude Code (default: 5)
30
- twin --help Show this message
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "twin-cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Your twin builds while you sleep. Not human in the loop. Twin in the loop.",
5
5
  "type": "module",
6
6
  "bin": {
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 iterations. Read this to understand what was already tried and learned.\n' : ''}
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 what makes sense given the current state of the codebase
37
- 3. Build it. Write real, working code. Follow the acceptance criteria.
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 for future iterations
40
- 6. Output ${COMPLETION_SIGNAL} when you finish a story
41
- 7. If ALL stories in prd.json are "done", output ${ALL_DONE_SIGNAL} instead
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
- - Commit your work with a clear message after completing a story
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
- export async function build(maxIterations = 5) {
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 in prd.json are already done. Run `twin plan` to generate more.\n');
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
- console.log(`\n--- twin build ---`);
227
- console.log(`Using ${twinFilename}`);
228
- console.log(`${openStories.length} stories remaining in prd.json`);
229
- console.log(`Max iterations: ${maxIterations}\n`);
230
-
231
- for (let i = 1; i <= maxIterations; i++) {
232
- console.log(`\n${'='.repeat(60)}`);
233
- console.log(` Iteration ${i} of ${maxIterations}`);
234
- console.log(`${'='.repeat(60)}\n`);
235
-
236
- // Re-read files each iteration (they may have been updated)
237
- const currentPrd = await readFile(prdPath, 'utf-8');
238
- const progressContent = await readIfExists(resolve(cwd, 'progress.md'));
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
- const prompt = buildPrompt(twinContent, twinFilename, currentPrd, progressContent);
241
- const { output, code } = await runIteration(prompt, cwd);
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
- if (code !== 0) {
244
- console.log(`\nClaude exited with code ${code}. Continuing to next iteration...\n`);
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 completion signals
248
- if (output.includes(ALL_DONE_SIGNAL)) {
249
- console.log(`\n${'='.repeat(60)}`);
250
- console.log(' All stories complete!');
251
- console.log(`${'='.repeat(60)}`);
252
- console.log('\nNext step plan more features:');
253
- console.log(' twin plan\n');
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
- if (output.includes(COMPLETION_SIGNAL)) {
258
- // Re-read PRD to check remaining stories
259
- const updatedPrd = JSON.parse(await readFile(prdPath, 'utf-8'));
260
- const remaining = updatedPrd.userStories.filter((s) => s.status !== 'done');
261
- if (remaining.length === 0) {
262
- console.log(`\n${'='.repeat(60)}`);
263
- console.log(' All stories complete!');
264
- console.log(`${'='.repeat(60)}`);
265
- console.log('\nNext step plan more features:');
266
- console.log(' twin plan\n');
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
- console.log(`\nStory complete. ${remaining.length} remaining.\n`);
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 (i === maxIterations) {
273
- console.log(`\nReached max iterations (${maxIterations}). Run \`twin build\` again to continue.\n`);
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
- const content = await callLLM(
47
- SYSTEM_PROMPT,
48
- `The builder's name is ${name}.\n\nHere are the interview answers. Generate the .twin file.\n\n${interviewText}`,
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! Your twin file is at: ${outPath}`);
55
- console.log('\nNext step — plan what to build:');
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
- console.log('\nGenerating your .twin file...\n');
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
- console.error(`Claude Code exited with code ${code}.`);
26
- if (stderr) console.error(stderr);
27
- process.exit(1);
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
- console.error('\nClaude Code is not installed or not in PATH.');
35
- console.error('Install it: https://docs.anthropic.com/en/docs/claude-code\n');
36
- process.exit(1);
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
- console.error(`\nFailed to spawn Claude: ${err.message}\n`);
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
- console.error('No content returned from Claude Code.');
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
- export async function plan() {
75
- // Find *.twin filerequired
76
- const cwd = process.cwd();
81
+ /**
82
+ * Headless plancallable 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
- console.error('No .twin file found. Run `twin init` first.\n');
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
- // Read or bootstrap product.md
91
- const productPath = resolve(process.cwd(), 'product.md');
92
- let product = await readIfExists(productPath);
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
- // Read optional context files
98
- const statusPath = resolve(process.cwd(), 'status.md');
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
- console.log('--- twin plan ---');
114
- console.log('Generating tasks that match your taste...\n');
115
-
116
- const raw = await callLLM(TASK_SYSTEM_PROMPT, userMessage);
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 (e) {
123
- console.error('Failed to parse LLM response as JSON. Raw output:\n');
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(process.cwd());
136
+ prd.project = basename(cwd);
131
137
  }
132
138
 
133
- // Merge with existing stories if prd.json already exists
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, ...prd.userStories];
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
- // Print stories to console
150
- for (const story of prd.userStories) {
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(`\nRun \`twin build\` to start building.`);
206
+ console.log(`\nNext step — let your twin build it:`);
207
+ console.log(` npx twin-cli build`);
161
208
  }