twin-cli 0.2.1 → 0.4.0

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,7 +2,7 @@
2
2
 
3
3
  Old TDD meant write the tests first. New TDD means write the twin first.
4
4
 
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.
5
+ A `.twin` file is the `.env` for your taste. 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.
6
6
 
7
7
  ## The Problem
8
8
 
@@ -18,8 +18,8 @@ The root cause: agents do not know what you would build next. They can execute.
18
18
 
19
19
  Three commands.
20
20
 
21
- ```
22
- twin init # encode how you think
21
+ ```bash
22
+ twin init # your taste, in a file
23
23
  twin plan # your twin decides what to build next
24
24
  twin build # your twin builds it without you
25
25
  ```
@@ -33,6 +33,8 @@ Run `twin plan` once and you get the next batch of features you would have chose
33
33
  - **Node.js 18+** — [nodejs.org](https://nodejs.org)
34
34
  - **Claude Code** — [docs.anthropic.com](https://docs.anthropic.com/en/docs/claude-code) (powers all three commands)
35
35
 
36
+ Run everything with `npx twin-cli` — no global install needed. This keeps you on the latest version automatically.
37
+
36
38
  ## Quick Start
37
39
 
38
40
  Each twin project starts in its own folder. Create one, then run from inside it.
@@ -82,13 +84,13 @@ npx twin-cli build
82
84
  # → Picks the next story, builds it, marks it done
83
85
  # → You watch it happen in real time
84
86
 
85
- # Want more features? Plan again.
87
+ # Want more? Plan again.
86
88
  npx twin-cli plan
87
89
  # → Sees what is done, generates the next batch
88
90
  npx twin-cli build
89
91
  ```
90
92
 
91
- Your `.twin` file is portable. Copy it into any new project and run `twin plan` to start.
93
+ Your `.twin` file is portable. Copy it into any new project and run `npx twin-cli plan` to start.
92
94
 
93
95
  ## Project Ideas
94
96
 
@@ -106,12 +108,12 @@ Twin works best on new projects. Some things to try:
106
108
 
107
109
  Your twin drives the whole cycle.
108
110
 
109
- 1. **`twin init`** — encode how you think (once)
110
- 2. **`twin plan`** — your twin generates tasks that match how you prioritize
111
+ 1. **`twin init`** — your taste, in a file (once)
112
+ 2. **`twin plan`** — your twin generates the next batch of stories, matched to how you prioritize
111
113
  3. **`twin build`** — your twin builds on its own, updating `prd.json` as stories complete
112
114
  4. **`twin plan` again** — your twin reads what shipped and decides what comes next
113
115
 
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.
116
+ You did not write a task list. Your twin wrote it. You did not pick the next feature. Your twin picked it.
115
117
 
116
118
  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.
117
119
 
@@ -119,7 +121,7 @@ Each iteration, Claude Code starts fresh but reads the files on disk. Your twin.
119
121
 
120
122
  ### `twin init`
121
123
 
122
- Asks your name, then 5 questions about how you build things. Generates `yourname.twin`.
124
+ Asks your name, then 5 questions about how you build. Generates `yourname.twin`.
123
125
 
124
126
  ### `twin plan`
125
127
 
@@ -136,15 +138,29 @@ Runs an autonomous build loop using Claude Code. Each iteration:
136
138
  - Picks the next story to build (guided by your taste)
137
139
  - Builds it, commits, and marks it done in `prd.json`
138
140
  - Appends learnings to `progress.md`
139
- - Repeats until all stories are done or max iterations hit
141
+ - Repeats until all stories are done or the story limit is hit
140
142
 
141
143
  ```bash
142
- twin build # default: 5 iterations
143
- twin build --max-iterations 10 # custom limit
144
+ npx twin-cli build # build the current stories (default: 3)
145
+ npx twin-cli build --loop # build, plan, build — fully autonomous
146
+ npx twin-cli build --loop --stories 20 # stop after 20 stories
147
+ npx twin-cli build --loop --minutes 30 # stop after 30 minutes
144
148
  ```
145
149
 
146
150
  Requires Claude Code installed and available in your PATH.
147
151
 
152
+ ### `twin steer`
153
+
154
+ You have something to say but don't want to write a user story yourself. Just say it:
155
+
156
+ ```bash
157
+ twin steer "I want users to be able to share their progress on Twitter"
158
+ ```
159
+
160
+ Or run `twin steer` with no arguments to type or dictate a longer message.
161
+
162
+ Your twin turns it into stories in `prd.json` and updates your `.twin` file with anything it can infer about your taste. Works any time — before a build, between runs, or from a second terminal while a build is running.
163
+
148
164
  ## What Goes in a `.twin` File
149
165
 
150
166
  - **Execution bias** — do you plan first or build first? Ship rough or wait for polish?
@@ -159,11 +175,11 @@ Requires Claude Code installed and available in your PATH.
159
175
  - Your tech stack (that goes in project config)
160
176
  - Your roadmap (that goes in a PRD)
161
177
 
162
- The twin encodes how you think. The project files encode what you are building. The twin is the founder. The project files are the company.
178
+ The twin holds your taste. The project files hold what you are building. The twin is the founder. The project files are the company.
163
179
 
164
- ## What Comes Next
180
+ ## Notes
165
181
 
166
- `twin tweak` for natural language updates. `twin share` for publishing your taste publicly.
182
+ **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.
167
183
 
168
184
  ## License
169
185
 
package/bin/twin.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import { init } from '../src/init.js';
4
4
  import { plan } from '../src/plan.js';
5
5
  import { build } from '../src/build.js';
6
+ import { steer } from '../src/steer.js';
6
7
 
7
8
  const command = process.argv[2];
8
9
 
@@ -17,17 +18,27 @@ if (!command || command === 'init') {
17
18
  } else if (command === 'plan') {
18
19
  plan();
19
20
  } else if (command === 'build') {
20
- const maxIterations = parseInt(parseFlag('--max-iterations', '5'), 10);
21
- build(maxIterations);
21
+ const loop = process.argv.includes('--loop');
22
+ const storiesFlag = parseFlag('--stories', null);
23
+ const minutesFlag = parseFlag('--minutes', null);
24
+ const maxStories = storiesFlag ? parseInt(storiesFlag, 10) : (loop ? Infinity : 3);
25
+ const maxMinutes = minutesFlag ? parseInt(minutesFlag, 10) : null;
26
+ build({ maxStories, loop, maxMinutes });
27
+ } else if (command === 'steer') {
28
+ steer();
22
29
  } else if (command === '--help' || command === '-h') {
23
30
  console.log(`
24
- twin - encode your decision-making DNA
31
+ twin - your twin builds while you sleep
25
32
 
26
33
  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
34
+ twin init Interview yourself, generate your .twin file
35
+ twin plan Your twin decides what to build next
36
+ twin build [--stories N] Build N stories using Claude Code (default: 3)
37
+ twin build --loop Build, plan, build — fully autonomous
38
+ twin build --loop --stories 20 Stop after 20 stories
39
+ twin build --loop --minutes 30 Stop after 30 minutes
40
+ twin steer [message] Tell your twin what to build next
41
+ twin --help Show this message
31
42
  `);
32
43
  } else {
33
44
  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.4.0",
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,9 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { readFile, writeFile, readdir } from 'node:fs/promises';
3
+ import { unlinkSync } from 'node:fs';
3
4
  import { resolve } from 'node:path';
5
+ import { runPlan } from './plan.js';
6
+ import { callLLM } from './llm.js';
4
7
 
5
8
  const COMPLETION_SIGNAL = '<twin>STORY_COMPLETE</twin>';
6
9
  const ALL_DONE_SIGNAL = '<twin>ALL_COMPLETE</twin>';
@@ -17,7 +20,7 @@ async function findTwinFile(cwd) {
17
20
  const files = await readdir(cwd);
18
21
  const twinFiles = files.filter((f) => f.endsWith('.twin'));
19
22
  if (twinFiles.length === 0) {
20
- console.error('No .twin file found. Run `twin init` first.\n');
23
+ console.error('No .twin file found. Run `npx twin-cli init` first.\n');
21
24
  process.exit(1);
22
25
  }
23
26
  return resolve(cwd, twinFiles[0]);
@@ -29,22 +32,23 @@ function buildPrompt(twinContent, twinFilename, prdContent, progressContent) {
29
32
  1. **The twin file** (${twinFilename}) — this is the builder's taste, their decision-making DNA. Build the way they would build.
30
33
  2. **prd.json** — the product requirements with user stories. Each story has a status: "open", "in_progress", or "done".
31
34
 
32
- ${progressContent ? '3. **progress.md** — notes from previous build iterations. Read this to understand what was already tried and learned.\n' : ''}
35
+ ${progressContent ? '3. **progress.md** — notes from previous build runs. Read this to understand what was already tried and learned.\n' : ''}
33
36
  ## Your task
34
37
 
35
38
  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.
39
+ 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
40
+ 3. Build that ONE story. Write real, working code. Follow the acceptance criteria.
38
41
  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
42
+ 5. Append to progress.md what you built, what files changed, and any learnings
43
+ 6. Commit your work with a clear message
44
+ 7. Output ${COMPLETION_SIGNAL} when you finish the story
45
+ 8. If ALL stories in prd.json are "done" after completing yours, output ${ALL_DONE_SIGNAL} instead
42
46
 
43
47
  ## Rules
48
+ - Build ONE story per run. Do not start a second story.
44
49
  - Build real features, not stubs
45
50
  - 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
51
+ - If you get stuck, note what blocked you in progress.md and output ${COMPLETION_SIGNAL} anyway
48
52
  - Do NOT ask questions. Make decisions based on the twin and the PRD.
49
53
 
50
54
  ## Twin file (${twinFilename})
@@ -106,6 +110,7 @@ function runIteration(prompt, cwd) {
106
110
  let buffer = '';
107
111
  let lastActivity = Date.now();
108
112
  let statusLine = false; // true when a status line is showing
113
+ let atLineStart = true; // track if cursor is at start of a line
109
114
 
110
115
  const TOOL_LABELS = {
111
116
  Read: 'Reading file',
@@ -124,6 +129,10 @@ function runIteration(prompt, cwd) {
124
129
  }
125
130
 
126
131
  function showStatus(msg) {
132
+ if (!atLineStart && !statusLine) {
133
+ process.stdout.write('\n');
134
+ atLineStart = true;
135
+ }
127
136
  clearStatus();
128
137
  const elapsed = Math.round((Date.now() - startTime) / 1000);
129
138
  process.stdout.write(`\r\x1b[2m${msg} (${elapsed}s)\x1b[0m`);
@@ -154,8 +163,15 @@ function runIteration(prompt, cwd) {
154
163
 
155
164
  if (event.type === 'text') {
156
165
  clearStatus();
157
- process.stdout.write(event.text);
158
166
  output += event.text;
167
+ // Hide completion signals from user — they're internal plumbing
168
+ const display = event.text
169
+ .replace(COMPLETION_SIGNAL, '')
170
+ .replace(ALL_DONE_SIGNAL, '');
171
+ if (display) {
172
+ process.stdout.write(display);
173
+ atLineStart = display.endsWith('\n');
174
+ }
159
175
  } else if (event.type === 'tool') {
160
176
  const label = TOOL_LABELS[event.name] || event.name;
161
177
  showStatus(label);
@@ -199,78 +215,329 @@ function runIteration(prompt, cwd) {
199
215
  });
200
216
  }
201
217
 
202
- export async function build(maxIterations = 5) {
218
+ // Dim helper for secondary/chrome text
219
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
220
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
221
+ const bar = dim('─'.repeat(60));
222
+
223
+ async function processSteer(cwd, twinPath, prdPath) {
224
+ const steerPath = resolve(cwd, 'steer.md');
225
+ const steerContent = await readIfExists(steerPath);
226
+ if (!steerContent?.trim()) return;
227
+
228
+ const twinContent = await readFile(twinPath, 'utf-8');
229
+ const twinFilename = twinPath.split('/').pop();
230
+ const prdContent = await readFile(prdPath, 'utf-8');
231
+ const prd = JSON.parse(prdContent);
232
+
233
+ const maxNum = prd.userStories.reduce((max, s) => {
234
+ const m = s.id?.match(/\d+/);
235
+ return m ? Math.max(max, parseInt(m[0], 10)) : max;
236
+ }, 0);
237
+ const nextId = `US-${String(maxNum + 1).padStart(3, '0')}`;
238
+
239
+ // Send trimmed prd (id + title + status only) to reduce token count
240
+ const prdSummary = prd.userStories
241
+ .map((s) => `${s.id}: ${s.title} (${s.status})`)
242
+ .join('\n');
243
+
244
+ const result = await callLLM(
245
+ 'You process developer steering input for a build loop. Output valid JSON only — no prose, no markdown fences.',
246
+ [
247
+ 'Steering input from developer:',
248
+ steerContent,
249
+ '',
250
+ `Existing stories:\n${prdSummary}`,
251
+ '',
252
+ `${twinFilename}:\n${twinContent}`,
253
+ '',
254
+ `Next available story id: ${nextId} (increment for each additional story)`,
255
+ '',
256
+ 'Output JSON with this exact shape:',
257
+ '{',
258
+ ' "newStories": [], // stories to add to prd.json (id, title, userStory, acceptanceCriteria array, status:"open"), empty array if none',
259
+ ' "twinAppend": null // text to append to the twin file if this input reveals something clear about the developer\'s taste, or null if nothing clear can be inferred',
260
+ '}',
261
+ ].join('\n')
262
+ );
263
+
264
+ let json;
265
+ try {
266
+ json = JSON.parse(result.replace(/^```(?:json)?\n?/m, '').replace(/\n?```$/m, '').trim());
267
+ } catch {
268
+ console.log(dim(' steer: could not parse response, skipping.'));
269
+ return;
270
+ }
271
+
272
+ if (json.newStories?.length > 0) {
273
+ prd.userStories.push(...json.newStories);
274
+ await writeFile(prdPath, JSON.stringify(prd, null, 2), 'utf-8');
275
+ console.log('');
276
+ for (const s of json.newStories) {
277
+ console.log(dim(' + ') + `${s.id} ${s.title}`);
278
+ }
279
+ }
280
+
281
+ if (json.twinAppend) {
282
+ const current = await readFile(twinPath, 'utf-8');
283
+ await writeFile(twinPath, current.trimEnd() + '\n\n' + json.twinAppend + '\n', 'utf-8');
284
+ console.log(dim(` Updated ${twinFilename}`));
285
+ }
286
+
287
+ // Clear steer.md so it isn't re-processed next cycle
288
+ await writeFile(steerPath, '', 'utf-8');
289
+ }
290
+
291
+ async function logPriorityJustification(twinContent, prdContent, storyNum, synthesisPath) {
292
+ const justification = await callLLM(
293
+ 'You are a prioritization oracle. Answer in 1-3 sentences only. No preamble.',
294
+ `Twin file:\n${twinContent}\n\nprd.json:\n${prdContent}\n\nIf you could only ship one story this cycle, which would it be and why? Reference the twin file reasoning explicitly.`
295
+ );
296
+ const existing = await readIfExists(synthesisPath) || '';
297
+ const entry = `\n## Story ${storyNum} — ${new Date().toISOString()}\n\n**Priority justification:** ${justification}\n`;
298
+ await writeFile(synthesisPath, existing + entry, 'utf-8');
299
+ }
300
+
301
+ const STORY_RETRIES = 2;
302
+ const STORY_RETRY_DELAYS = [10_000, 30_000]; // 10s, then 30s
303
+
304
+ function sleep(ms) {
305
+ return new Promise((r) => setTimeout(r, ms));
306
+ }
307
+
308
+ // Show an overwriting status line while an async operation runs, then clear it.
309
+ // msg can be a string, or an array of [afterSeconds, message] milestones.
310
+ async function withStatus(msg, fn) {
311
+ const milestones = Array.isArray(msg)
312
+ ? msg.slice().sort((a, b) => a[0] - b[0])
313
+ : [[0, msg]];
314
+ const start = Date.now();
315
+ let shown = false;
316
+ const timer = setInterval(() => {
317
+ const elapsed = Math.round((Date.now() - start) / 1000);
318
+ const current = milestones.filter(([s]) => elapsed >= s).pop()[1];
319
+ process.stdout.write(`\r${dim(`${current} (${elapsed}s)`)}`);
320
+ shown = true;
321
+ }, 3_000);
322
+ try {
323
+ return await fn();
324
+ } finally {
325
+ clearInterval(timer);
326
+ if (shown) process.stdout.write('\r\x1b[K');
327
+ }
328
+ }
329
+
330
+ function summary(totalBuilt, cycles, elapsed) {
331
+ const mins = Math.round(elapsed / 60000);
332
+ const time = mins > 0 ? ` in ${mins}m` : '';
333
+ const cycleNote = cycles > 1 ? ` across ${cycles} cycles` : '';
334
+ return `${totalBuilt} stories built${cycleNote}${time}`;
335
+ }
336
+
337
+ export async function build({ maxStories = 3, loop = false, maxMinutes = null } = {}) {
203
338
  const cwd = process.cwd();
339
+ const startTime = Date.now();
340
+ const timeLimitMs = maxMinutes ? maxMinutes * 60 * 1000 : null;
341
+
342
+ // Write lock file so twin steer knows a build is running
343
+ const lockPath = resolve(cwd, '.twin-lock');
344
+ await writeFile(lockPath, String(process.pid), 'utf-8');
345
+ process.on('exit', () => { try { unlinkSync(lockPath); } catch {} });
346
+
347
+ // Clean exit on Ctrl+C — finish current story, then stop
348
+ let stopping = false;
349
+ process.on('SIGINT', () => {
350
+ if (stopping) process.exit(1); // Second Ctrl+C forces exit
351
+ stopping = true;
352
+ console.log(dim('\n\nStopping after current story finishes...\n'));
353
+ });
204
354
 
205
355
  // Find twin file
206
356
  const twinPath = await findTwinFile(cwd);
207
357
  const twinFilename = twinPath.split('/').pop();
208
- const twinContent = await readFile(twinPath, 'utf-8');
209
358
 
210
359
  // Read prd.json — required
211
360
  const prdPath = resolve(cwd, 'prd.json');
212
361
  const prdContent = await readIfExists(prdPath);
213
362
  if (!prdContent) {
214
- console.error('No prd.json found. Run `twin plan` first.\n');
363
+ console.error('No prd.json found. Run `npx twin-cli plan` first.\n');
215
364
  process.exit(1);
216
365
  }
217
366
 
218
367
  // Check if there are open stories
219
368
  const prd = JSON.parse(prdContent);
220
369
  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');
370
+ if (openStories.length === 0 && !loop) {
371
+ console.log('All stories are done.\n');
372
+ console.log(' npx twin-cli plan # plan the next batch, then build');
373
+ console.log(' npx twin-cli build --loop # plan and build automatically\n');
223
374
  process.exit(0);
224
375
  }
376
+ // In loop mode with no open stories, the while loop will trigger planning
377
+
378
+ console.log('');
379
+ console.log(bar);
380
+ console.log(bold(` twin build${loop ? ' --loop' : ''}`));
381
+ console.log(dim(` ${twinFilename}`));
382
+ if (loop) {
383
+ const limits = [];
384
+ if (maxStories !== Infinity) limits.push(`${maxStories} stories`);
385
+ if (maxMinutes) limits.push(`${maxMinutes} min`);
386
+ console.log(dim(` ${limits.length > 0 ? limits.join(' · ') : 'no limit — Ctrl+C to stop'}`));
387
+ } else {
388
+ const storiesThisRun = Math.min(maxStories, openStories.length);
389
+ console.log(dim(` ${openStories.length} remaining · building ${storiesThisRun}`));
390
+ }
391
+ console.log(bar);
392
+ console.log('');
393
+
394
+ let totalBuilt = 0;
395
+ let cycle = 1;
396
+
397
+ while (totalBuilt < maxStories) {
398
+ // Check if user requested stop
399
+ if (stopping) {
400
+ console.log(bar);
401
+ console.log(bold(' Stopped'));
402
+ console.log(dim(` ${summary(totalBuilt, cycle, Date.now() - startTime)}`));
403
+ console.log(bar);
404
+ console.log('');
405
+ break;
406
+ }
225
407
 
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'));
408
+ // Re-read twin each cycle (user may tweak mid-run)
409
+ const twinContent = await readFile(twinPath, 'utf-8');
410
+
411
+ // Process any steering input before deciding what to build next
412
+ await withStatus([
413
+ [0, 'Applying your steer...'],
414
+ [20, 'Still working...'],
415
+ [60, 'Your message is detailed — taking a moment...'],
416
+ [120, 'Almost there...'],
417
+ ], () => processSteer(cwd, twinPath, prdPath)).catch(() => {});
418
+
419
+ // Re-read prd.json (steer or previous story may have updated it)
420
+ const currentPrdContent = await readFile(prdPath, 'utf-8');
421
+ const currentPrd = JSON.parse(currentPrdContent);
422
+ const remaining = currentPrd.userStories.filter((s) => s.status !== 'done');
423
+
424
+ // No open stories — either plan more or exit
425
+ if (remaining.length === 0) {
426
+ if (!loop) {
427
+ console.log('');
428
+ console.log(bar);
429
+ console.log(bold(' All stories complete'));
430
+ console.log(dim(` ${summary(totalBuilt, cycle, Date.now() - startTime)}`));
431
+ console.log(bar);
432
+ console.log('\n npx twin-cli plan # plan the next batch, then build');
433
+ console.log(' npx twin-cli build --loop # plan and build automatically\n');
434
+ break;
435
+ }
239
436
 
240
- const prompt = buildPrompt(twinContent, twinFilename, currentPrd, progressContent);
241
- const { output, code } = await runIteration(prompt, cwd);
437
+ // Loop mode ask the twin to plan the next batch
438
+ console.log('');
439
+ console.log(bar);
440
+ console.log(bold(` Cycle ${cycle} complete`));
441
+ console.log(bar);
442
+ console.log('');
443
+
444
+ const newStories = await withStatus('Planning...', () => runPlan(cwd));
445
+
446
+ if (newStories.length === 0) {
447
+ console.log(bar);
448
+ console.log(bold(' Your twin has built everything it would build right now.'));
449
+ console.log(dim(` ${summary(totalBuilt, cycle, Date.now() - startTime)}`));
450
+ console.log(bar);
451
+ console.log('');
452
+ break;
453
+ }
242
454
 
243
- if (code !== 0) {
244
- console.log(`\nClaude exited with code ${code}. Continuing to next iteration...\n`);
455
+ console.log(`Planned ${newStories.length} new stories:`);
456
+ for (const story of newStories) {
457
+ console.log(dim(` ${story.id}`) + ` ${story.title}`);
458
+ }
459
+ console.log('');
460
+ cycle++;
461
+ continue; // Back to top of while loop to build them
245
462
  }
246
463
 
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');
464
+ // Check time limit before starting next story
465
+ if (timeLimitMs && (Date.now() - startTime) >= timeLimitMs) {
466
+ const mins = Math.round((Date.now() - startTime) / 60000);
467
+ console.log(bar);
468
+ console.log(bold(' Time limit reached'));
469
+ console.log(dim(` ${summary(totalBuilt, cycle, Date.now() - startTime)}`));
470
+ console.log(bar);
471
+ console.log('');
254
472
  break;
255
473
  }
256
474
 
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');
475
+ // Build one story
476
+ const storyNum = totalBuilt + 1;
477
+
478
+ console.log('');
479
+ console.log(bar);
480
+ if (maxStories === Infinity) {
481
+ console.log(bold(` Story ${storyNum}`));
482
+ } else {
483
+ console.log(bold(` Story ${storyNum} of ${maxStories}`));
484
+ }
485
+ console.log(bar);
486
+ console.log('');
487
+
488
+ const progressContent = await readIfExists(resolve(cwd, 'progress.md'));
489
+
490
+ // Log priority justification to synthesis.md before building
491
+ await withStatus('Thinking...', () =>
492
+ logPriorityJustification(twinContent, currentPrdContent, storyNum, resolve(cwd, 'synthesis.md'))
493
+ ).catch(() => {});
494
+
495
+ const prompt = buildPrompt(twinContent, twinFilename, currentPrdContent, progressContent);
496
+
497
+ let succeeded = false;
498
+ for (let attempt = 0; attempt <= STORY_RETRIES; attempt++) {
499
+ if (attempt > 0) {
500
+ const delay = STORY_RETRY_DELAYS[attempt - 1];
501
+ console.log(dim(`\n API error — retrying in ${delay / 1000}s...\n`));
502
+ await sleep(delay);
503
+ }
504
+
505
+ const { output, code } = await runIteration(prompt, cwd);
506
+
507
+ if (code !== 0) {
508
+ if (attempt < STORY_RETRIES) continue; // retry
509
+ console.log(dim(`\nClaude exited with code ${code} after ${STORY_RETRIES + 1} attempts. Skipping story.\n`));
510
+ // Log the failure to progress.md so the next run knows
511
+ const progressPath = resolve(cwd, 'progress.md');
512
+ const existing = await readIfExists(progressPath) || '';
513
+ const note = `\n## Skipped story (${new Date().toISOString()})\nClaude exited with code ${code} after ${STORY_RETRIES + 1} attempts. Story was not counted.\n`;
514
+ await writeFile(progressPath, existing + note, 'utf-8');
267
515
  break;
268
516
  }
269
- console.log(`\nStory complete. ${remaining.length} remaining.\n`);
517
+
518
+ succeeded = true;
519
+ if (output.includes(COMPLETION_SIGNAL) || output.includes(ALL_DONE_SIGNAL)) {
520
+ const afterPrd = JSON.parse(await readFile(prdPath, 'utf-8'));
521
+ const left = afterPrd.userStories.filter((s) => s.status !== 'done');
522
+ if (left.length > 0) {
523
+ console.log(dim(`\nStory done. ${left.length} open in prd.json.\n`));
524
+ }
525
+ }
526
+ break;
270
527
  }
271
528
 
272
- if (i === maxIterations) {
273
- console.log(`\nReached max iterations (${maxIterations}). Run \`twin build\` again to continue.\n`);
529
+ if (succeeded) totalBuilt++;
530
+ }
531
+
532
+ if (totalBuilt > 0 && totalBuilt >= maxStories) {
533
+ console.log(bar);
534
+ console.log(bold(` Done`));
535
+ console.log(dim(` ${summary(totalBuilt, cycle, Date.now() - startTime)}`));
536
+ console.log(bar);
537
+ if (!loop) {
538
+ console.log('\n npx twin-cli build # keep building');
539
+ console.log(' npx twin-cli build --loop # build and plan automatically\n');
274
540
  }
541
+ console.log('');
275
542
  }
276
543
  }
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,11 @@ 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 # build the next 3 stories`);
208
+ console.log(` npx twin-cli build --loop # build and plan automatically`);
161
209
  }
package/src/steer.js ADDED
@@ -0,0 +1,37 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { createPrompter } from './prompt.js';
5
+ import { build } from './build.js';
6
+
7
+ export async function steer() {
8
+ const cwd = process.cwd();
9
+ const steerPath = resolve(cwd, 'steer.md');
10
+
11
+ // Accept message as inline args or fall back to interactive prompt
12
+ const inline = process.argv.slice(3).join(' ').trim();
13
+ let message;
14
+
15
+ if (inline) {
16
+ message = inline;
17
+ } else {
18
+ const prompter = createPrompter();
19
+ message = await prompter.ask('What do you want to tell your twin?');
20
+ prompter.close();
21
+ }
22
+
23
+ if (!message) {
24
+ console.log('No message provided. Nothing written.');
25
+ process.exit(0);
26
+ }
27
+
28
+ await writeFile(steerPath, message, 'utf-8');
29
+
30
+ const lockPath = resolve(cwd, '.twin-lock');
31
+ if (existsSync(lockPath)) {
32
+ console.log('\nGot it. Your twin will pick this up at the next story boundary.\n');
33
+ } else {
34
+ console.log('');
35
+ await build({ maxStories: 1 });
36
+ }
37
+ }