twin-cli 0.2.0 → 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
@@ -2,30 +2,32 @@
2
2
 
3
3
  Old TDD meant write the tests first. New TDD means write the twin first.
4
4
 
5
- A `.twin` file is your decision-making source code. Your taste. Your biases. Your heuristics. Drop it into any project and your AI tools make decisions the way you would.
6
-
7
- The twin is not a PRD. It is not a project spec. It is you.
5
+ A `.twin` file encodes how you think. Drop it into any project. Your AI does not wait for instructions. It decides what to build next and builds it. Not human in the loop. Twin in the loop.
8
6
 
9
7
  ## The Problem
10
8
 
11
- AI agents have velocity but no vector. They do what you tell them. When you stop telling them, they stop.
9
+ You give an agent a task list. It finishes. It stops. Now you have to decide what comes next.
12
10
 
13
- You are not writing code. But you are checking at 2am. Writing the next batch of tasks. Going back to sleep. Waking at 5am to check again.
11
+ So you check at 2am. Write the next batch. Go back to sleep. Wake at 5am. Check again. You are not writing code but you are on call around the clock.
14
12
 
15
13
  This is Vampire Coding. Hands-off in theory. Consumed in practice.
16
14
 
17
- The root cause: agents do not know what you would build next. They do not have your taste.
15
+ The root cause: agents do not know what you would build next. They can execute. They cannot decide.
18
16
 
19
17
  ## The Fix
20
18
 
21
19
  Three commands.
22
20
 
23
21
  ```
24
- twin init # encode your taste
25
- twin plan # your twin decides what to build
26
- twin build # your twin builds it
22
+ twin init # encode how you think
23
+ twin plan # your twin decides what to build next
24
+ twin build # your twin builds it without you
27
25
  ```
28
26
 
27
+ The difference between a `.twin` file and a Claude MD or a rules file: those are instructions. This is a decision-maker. Your twin knows how you think and decides what comes next.
28
+
29
+ Run `twin plan` once and you get the next batch of features you would have chosen. Run it again and you get the batch after that. Keep running it and you are watching your own product roadmap unfold without writing a single task yourself.
30
+
29
31
  ## Before You Start
30
32
 
31
33
  - **Node.js 18+** — [nodejs.org](https://nodejs.org)
@@ -33,13 +35,13 @@ twin build # your twin builds it
33
35
 
34
36
  ## Quick Start
35
37
 
36
- Every twin project starts in its own folder. Create one, then run the commands from inside it.
38
+ Each twin project starts in its own folder. Create one, then run from inside it.
37
39
 
38
40
  ```bash
39
41
  # 0. Create a project folder
40
42
  mkdir my-app && cd my-app
41
43
 
42
- # 1. Create your twin (only need to do this once)
44
+ # 1. Create your twin (once)
43
45
  npx twin-cli init
44
46
  # → Asks your name, then 5 questions about how you build
45
47
  # → Generates yourname.twin
@@ -56,7 +58,7 @@ npx twin-cli build
56
58
 
57
59
  ## Example: Building a Habit Tracker
58
60
 
59
- Here is a full walkthrough from zero to working app.
61
+ A full walkthrough from zero to working app.
60
62
 
61
63
  ```bash
62
64
  # Create the project
@@ -92,22 +94,24 @@ Your `.twin` file is portable. Copy it into any new project and run `twin plan`
92
94
 
93
95
  Twin works best on new projects. Some things to try:
94
96
 
95
- - **Habit tracker** with streaks and daily timers
96
- - **Personal landing page** with email capture and a changelog
97
- - **Micro-SaaS dashboard** for tracking one metric
98
- - **CLI tool** that solves a problem you keep solving manually
99
- - **PWA** that replaces a spreadsheet you use every day
97
+ - Habit tracker with streaks and daily timers
98
+ - Personal landing page with email capture and a changelog
99
+ - Micro-SaaS dashboard for tracking one metric
100
+ - CLI tool that solves a problem you keep solving by hand
101
+ - PWA that replaces a spreadsheet you use each day
100
102
 
101
103
  ## The Loop
102
104
 
103
105
  `init → plan → build → plan`
104
106
 
105
- This is the core cycle. Your twin drives the whole thing.
107
+ Your twin drives the whole cycle.
106
108
 
107
- 1. **`twin init`** — create your taste profile (once)
109
+ 1. **`twin init`** — encode how you think (once)
108
110
  2. **`twin plan`** — your twin generates tasks that match how you prioritize
109
111
  3. **`twin build`** — your twin builds on its own, updating `prd.json` as stories complete
110
- 4. **`twin plan` again** — your twin reads what shipped and plans what comes next
112
+ 4. **`twin plan` again** — your twin reads what shipped and decides what comes next
113
+
114
+ You did not write a task list. The twin wrote it. You did not pick the next feature. The twin picked it. That is the shift: from reactive to proactive.
111
115
 
112
116
  Each iteration, Claude Code starts fresh but reads the files on disk. Your twin. The PRD. A progress log. The files are the memory. Your taste stays consistent across runs.
113
117
 
@@ -119,7 +123,7 @@ Asks your name, then 5 questions about how you build things. Generates `yourname
119
123
 
120
124
  ### `twin plan`
121
125
 
122
- Reads your `.twin` file and project context. Generates 3-5 atomic capabilities that match your taste. Writes `prd.json` with user stories and status tracking.
126
+ Reads your `.twin` file and project context. Generates 3-5 capabilities that match your taste. Writes `prd.json` with user stories and status tracking.
123
127
 
124
128
  If no `product.md` exists, `twin plan` asks 2 quick questions to set up your project context first. Running it again adds new stories without duplicating old ones.
125
129
 
@@ -127,16 +131,18 @@ If no `product.md` exists, `twin plan` asks 2 quick questions to set up your pro
127
131
 
128
132
  Runs an autonomous build loop using Claude Code. Each iteration:
129
133
 
130
- - Reads your twin file for taste
134
+ - Reads your twin file for how you think
131
135
  - Reads `prd.json` for open stories
132
- - Picks the next story to build (the model decides, guided by your taste)
136
+ - Picks the next story to build (guided by your taste)
133
137
  - Builds it, commits, and marks it done in `prd.json`
134
138
  - Appends learnings to `progress.md`
135
139
  - Repeats until all stories are done or max iterations hit
136
140
 
137
141
  ```bash
138
- twin build # default: 5 iterations
139
- 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
140
146
  ```
141
147
 
142
148
  Requires Claude Code installed and available in your PATH.
@@ -146,7 +152,7 @@ Requires Claude Code installed and available in your PATH.
146
152
  - **Execution bias** — do you plan first or build first? Ship rough or wait for polish?
147
153
  - **Quality compass** — how do you define "done"? What does "good" look like to you?
148
154
  - **Decision-making style** — how do you get unstuck? How do you weigh trade-offs?
149
- - **Strong beliefs** — what do you believe that most people would disagree with?
155
+ - **Strong beliefs** — what do you believe that most would disagree with?
150
156
  - **Anti-patterns** — what do you refuse to do?
151
157
 
152
158
  ## What Does NOT Go in a `.twin` File
@@ -159,7 +165,11 @@ The twin encodes how you think. The project files encode what you are building.
159
165
 
160
166
  ## What Comes Next
161
167
 
162
- `twin tweak` for natural language updates. `twin share` for publishing your taste.
168
+ `twin tweak` for natural language updates. `twin share` for publishing your taste publicly.
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.
163
173
 
164
174
  ## License
165
175
 
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.0",
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})
@@ -56,27 +58,33 @@ ${progressContent ? `\n## progress.md\n${progressContent}` : ''}
56
58
  `;
57
59
  }
58
60
 
59
- function extractTextFromEvent(jsonLine) {
61
+ function parseEvent(jsonLine) {
60
62
  try {
61
63
  const event = JSON.parse(jsonLine);
62
64
 
63
65
  // Text delta from assistant message streaming
64
66
  if (event.type === 'assistant' && event.message?.content) {
65
- // Full message content (final)
66
- return event.message.content
67
+ const text = event.message.content
67
68
  .filter((block) => block.type === 'text')
68
69
  .map((block) => block.text)
69
70
  .join('');
71
+ if (text) return { type: 'text', text };
70
72
  }
71
73
 
72
74
  // Partial streaming text delta
73
75
  if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
74
- return event.delta.text;
76
+ return { type: 'text', text: event.delta.text };
75
77
  }
76
78
 
77
- // Result message with text
78
- if (event.type === 'result' && event.result) {
79
- return null; // Already captured via deltas
79
+ // Tool use show what Claude is doing
80
+ if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
81
+ const name = event.content_block.name || 'working';
82
+ return { type: 'tool', name };
83
+ }
84
+
85
+ // Tool result
86
+ if (event.type === 'content_block_start' && event.content_block?.type === 'tool_result') {
87
+ return { type: 'tool_done' };
80
88
  }
81
89
  } catch {
82
90
  // Not valid JSON or unexpected shape — skip
@@ -98,16 +106,46 @@ function runIteration(prompt, cwd) {
98
106
 
99
107
  let output = '';
100
108
  let buffer = '';
101
- let hasReceivedText = false;
109
+ let lastActivity = Date.now();
110
+ let statusLine = false; // true when a status line is showing
111
+ let atLineStart = true; // track if cursor is at start of a line
112
+
113
+ const TOOL_LABELS = {
114
+ Read: 'Reading file',
115
+ Write: 'Writing file',
116
+ Edit: 'Editing file',
117
+ Bash: 'Running command',
118
+ Glob: 'Searching files',
119
+ Grep: 'Searching code',
120
+ };
121
+
122
+ function clearStatus() {
123
+ if (statusLine) {
124
+ process.stdout.write('\r\x1b[K');
125
+ statusLine = false;
126
+ }
127
+ }
128
+
129
+ function showStatus(msg) {
130
+ if (!atLineStart && !statusLine) {
131
+ process.stdout.write('\n');
132
+ atLineStart = true;
133
+ }
134
+ clearStatus();
135
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
136
+ process.stdout.write(`\r\x1b[2m${msg} (${elapsed}s)\x1b[0m`);
137
+ statusLine = true;
138
+ }
102
139
 
103
140
  // Heartbeat timer — show elapsed time while waiting
104
141
  const startTime = Date.now();
105
142
  const heartbeat = setInterval(() => {
106
143
  const elapsed = Math.round((Date.now() - startTime) / 1000);
107
- if (!hasReceivedText) {
108
- process.stdout.write(`\rWorking... (${elapsed}s)`);
144
+ const idle = Date.now() - lastActivity;
145
+ if (idle > 5_000) {
146
+ showStatus('Working...');
109
147
  }
110
- }, 10_000);
148
+ }, 5_000);
111
149
 
112
150
  claude.stdout.on('data', (chunk) => {
113
151
  buffer += chunk.toString();
@@ -116,15 +154,27 @@ function runIteration(prompt, cwd) {
116
154
 
117
155
  for (const line of lines) {
118
156
  if (!line.trim()) continue;
119
- const text = extractTextFromEvent(line);
120
- if (text) {
121
- if (!hasReceivedText) {
122
- // Clear the heartbeat line on first real output
123
- process.stdout.write('\r\x1b[K');
124
- hasReceivedText = true;
157
+ const event = parseEvent(line);
158
+ if (!event) continue;
159
+
160
+ lastActivity = Date.now();
161
+
162
+ if (event.type === 'text') {
163
+ clearStatus();
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');
125
172
  }
126
- process.stdout.write(text);
127
- output += text;
173
+ } else if (event.type === 'tool') {
174
+ const label = TOOL_LABELS[event.name] || event.name;
175
+ showStatus(label);
176
+ } else if (event.type === 'tool_done') {
177
+ clearStatus();
128
178
  }
129
179
  }
130
180
  });
@@ -135,17 +185,15 @@ function runIteration(prompt, cwd) {
135
185
 
136
186
  claude.on('close', (code) => {
137
187
  clearInterval(heartbeat);
188
+ clearStatus();
138
189
  // Process any remaining buffer
139
190
  if (buffer.trim()) {
140
- const text = extractTextFromEvent(buffer.trim());
141
- if (text) {
142
- process.stdout.write(text);
143
- output += text;
191
+ const event = parseEvent(buffer.trim());
192
+ if (event?.type === 'text') {
193
+ process.stdout.write(event.text);
194
+ output += event.text;
144
195
  }
145
196
  }
146
- if (!hasReceivedText) {
147
- process.stdout.write('\r\x1b[K'); // Clear heartbeat line
148
- }
149
197
  resolvePromise({ output, code });
150
198
  });
151
199
 
@@ -165,78 +213,208 @@ function runIteration(prompt, cwd) {
165
213
  });
166
214
  }
167
215
 
168
- 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 } = {}) {
169
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
+ });
170
247
 
171
248
  // Find twin file
172
249
  const twinPath = await findTwinFile(cwd);
173
250
  const twinFilename = twinPath.split('/').pop();
174
- const twinContent = await readFile(twinPath, 'utf-8');
175
251
 
176
252
  // Read prd.json — required
177
253
  const prdPath = resolve(cwd, 'prd.json');
178
254
  const prdContent = await readIfExists(prdPath);
179
255
  if (!prdContent) {
180
- 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');
181
257
  process.exit(1);
182
258
  }
183
259
 
184
260
  // Check if there are open stories
185
261
  const prd = JSON.parse(prdContent);
186
262
  const openStories = prd.userStories.filter((s) => s.status !== 'done');
187
- if (openStories.length === 0) {
188
- 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');
189
265
  process.exit(0);
190
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
+ }
191
298
 
192
- console.log(`\n--- twin build ---`);
193
- console.log(`Using ${twinFilename}`);
194
- console.log(`${openStories.length} stories remaining in prd.json`);
195
- console.log(`Max iterations: ${maxIterations}\n`);
196
-
197
- for (let i = 1; i <= maxIterations; i++) {
198
- console.log(`\n${'='.repeat(60)}`);
199
- console.log(` Iteration ${i} of ${maxIterations}`);
200
- console.log(`${'='.repeat(60)}\n`);
201
-
202
- // Re-read files each iteration (they may have been updated)
203
- const currentPrd = await readFile(prdPath, 'utf-8');
204
- 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
+ }
205
319
 
206
- const prompt = buildPrompt(twinContent, twinFilename, currentPrd, progressContent);
207
- 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
+ }
208
338
 
209
- if (code !== 0) {
210
- 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
211
346
  }
212
347
 
213
- // Check completion signals
214
- if (output.includes(ALL_DONE_SIGNAL)) {
215
- console.log(`\n${'='.repeat(60)}`);
216
- console.log(' All stories complete!');
217
- console.log(`${'='.repeat(60)}`);
218
- console.log('\nNext step plan more features:');
219
- 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('');
220
356
  break;
221
357
  }
222
358
 
223
- if (output.includes(COMPLETION_SIGNAL)) {
224
- // Re-read PRD to check remaining stories
225
- const updatedPrd = JSON.parse(await readFile(prdPath, 'utf-8'));
226
- const remaining = updatedPrd.userStories.filter((s) => s.status !== 'done');
227
- if (remaining.length === 0) {
228
- console.log(`\n${'='.repeat(60)}`);
229
- console.log(' All stories complete!');
230
- console.log(`${'='.repeat(60)}`);
231
- console.log('\nNext step plan more features:');
232
- 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');
233
393
  break;
234
394
  }
235
- 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;
236
405
  }
237
406
 
238
- if (i === maxIterations) {
239
- 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');
240
417
  }
418
+ console.log('');
241
419
  }
242
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,31 +109,89 @@ 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);
137
+ }
138
+
139
+ const newStories = prd.userStories || [];
140
+
141
+ // Merge with existing stories
142
+ if (existingPrd) {
143
+ try {
144
+ const existing = JSON.parse(existingPrd);
145
+ const existingStories = existing.userStories || [];
146
+ prd.userStories = [...existingStories, ...newStories];
147
+ if (!prd.project && existing.project) prd.project = existing.project;
148
+ if (!prd.description && existing.description) prd.description = existing.description;
149
+ } catch {
150
+ // If existing prd.json is malformed, just use the new one
151
+ }
131
152
  }
132
153
 
133
- // Write prd.json
134
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
+ }
182
+
183
+ console.log('--- twin plan ---');
184
+ console.log('Your twin is deciding what to build next...\n');
185
+
186
+ const newStories = await runPlan(cwd);
135
187
 
136
- // Print stories to console
137
- for (const story of prd.userStories) {
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) {
138
195
  console.log(`${story.id}. ${story.title}`);
139
196
  console.log(` ${story.description}`);
140
197
  for (const ac of story.acceptanceCriteria) {
@@ -142,7 +199,10 @@ export async function plan() {
142
199
  }
143
200
  console.log('');
144
201
  }
202
+
203
+ const prdPath = resolve(cwd, 'prd.json');
145
204
  console.log(`---`);
146
205
  console.log(`Wrote ${prdPath}`);
147
- 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`);
148
208
  }