twin-cli 0.3.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,17 +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 # 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
+ 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
146
148
  ```
147
149
 
148
150
  Requires Claude Code installed and available in your PATH.
149
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
+
150
164
  ## What Goes in a `.twin` File
151
165
 
152
166
  - **Execution bias** — do you plan first or build first? Ship rough or wait for polish?
@@ -161,11 +175,7 @@ Requires Claude Code installed and available in your PATH.
161
175
  - Your tech stack (that goes in project config)
162
176
  - Your roadmap (that goes in a PRD)
163
177
 
164
- 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.
165
-
166
- ## What Comes Next
167
-
168
- `twin tweak` for natural language updates. `twin share` for publishing your taste publicly.
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.
169
179
 
170
180
  ## Notes
171
181
 
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
 
@@ -23,6 +24,8 @@ if (!command || command === 'init') {
23
24
  const maxStories = storiesFlag ? parseInt(storiesFlag, 10) : (loop ? Infinity : 3);
24
25
  const maxMinutes = minutesFlag ? parseInt(minutesFlag, 10) : null;
25
26
  build({ maxStories, loop, maxMinutes });
27
+ } else if (command === 'steer') {
28
+ steer();
26
29
  } else if (command === '--help' || command === '-h') {
27
30
  console.log(`
28
31
  twin - your twin builds while you sleep
@@ -34,6 +37,7 @@ Usage:
34
37
  twin build --loop Build, plan, build — fully autonomous
35
38
  twin build --loop --stories 20 Stop after 20 stories
36
39
  twin build --loop --minutes 30 Stop after 30 minutes
40
+ twin steer [message] Tell your twin what to build next
37
41
  twin --help Show this message
38
42
  `);
39
43
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "twin-cli",
3
- "version": "0.3.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,7 +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';
4
5
  import { runPlan } from './plan.js';
6
+ import { callLLM } from './llm.js';
5
7
 
6
8
  const COMPLETION_SIGNAL = '<twin>STORY_COMPLETE</twin>';
7
9
  const ALL_DONE_SIGNAL = '<twin>ALL_COMPLETE</twin>';
@@ -218,6 +220,84 @@ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
218
220
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
219
221
  const bar = dim('─'.repeat(60));
220
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
+
221
301
  const STORY_RETRIES = 2;
222
302
  const STORY_RETRY_DELAYS = [10_000, 30_000]; // 10s, then 30s
223
303
 
@@ -225,6 +305,28 @@ function sleep(ms) {
225
305
  return new Promise((r) => setTimeout(r, ms));
226
306
  }
227
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
+
228
330
  function summary(totalBuilt, cycles, elapsed) {
229
331
  const mins = Math.round(elapsed / 60000);
230
332
  const time = mins > 0 ? ` in ${mins}m` : '';
@@ -237,6 +339,11 @@ export async function build({ maxStories = 3, loop = false, maxMinutes = null }
237
339
  const startTime = Date.now();
238
340
  const timeLimitMs = maxMinutes ? maxMinutes * 60 * 1000 : null;
239
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
+
240
347
  // Clean exit on Ctrl+C — finish current story, then stop
241
348
  let stopping = false;
242
349
  process.on('SIGINT', () => {
@@ -261,7 +368,9 @@ export async function build({ maxStories = 3, loop = false, maxMinutes = null }
261
368
  const prd = JSON.parse(prdContent);
262
369
  const openStories = prd.userStories.filter((s) => s.status !== 'done');
263
370
  if (openStories.length === 0 && !loop) {
264
- console.log('All stories are done. Plan the next batch:\n npx twin-cli plan\n');
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');
265
374
  process.exit(0);
266
375
  }
267
376
  // In loop mode with no open stories, the while loop will trigger planning
@@ -299,7 +408,15 @@ export async function build({ maxStories = 3, loop = false, maxMinutes = null }
299
408
  // Re-read twin each cycle (user may tweak mid-run)
300
409
  const twinContent = await readFile(twinPath, 'utf-8');
301
410
 
302
- // Re-read prd.json (previous story or plan cycle may have updated it)
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)
303
420
  const currentPrdContent = await readFile(prdPath, 'utf-8');
304
421
  const currentPrd = JSON.parse(currentPrdContent);
305
422
  const remaining = currentPrd.userStories.filter((s) => s.status !== 'done');
@@ -312,8 +429,8 @@ export async function build({ maxStories = 3, loop = false, maxMinutes = null }
312
429
  console.log(bold(' All stories complete'));
313
430
  console.log(dim(` ${summary(totalBuilt, cycle, Date.now() - startTime)}`));
314
431
  console.log(bar);
315
- console.log('\nNext step plan more features:');
316
- console.log(' npx twin-cli plan\n');
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');
317
434
  break;
318
435
  }
319
436
 
@@ -321,11 +438,10 @@ export async function build({ maxStories = 3, loop = false, maxMinutes = null }
321
438
  console.log('');
322
439
  console.log(bar);
323
440
  console.log(bold(` Cycle ${cycle} complete`));
324
- console.log(dim(' Your twin is planning the next batch...'));
325
441
  console.log(bar);
326
442
  console.log('');
327
443
 
328
- const newStories = await runPlan(cwd);
444
+ const newStories = await withStatus('Planning...', () => runPlan(cwd));
329
445
 
330
446
  if (newStories.length === 0) {
331
447
  console.log(bar);
@@ -370,6 +486,12 @@ export async function build({ maxStories = 3, loop = false, maxMinutes = null }
370
486
  console.log('');
371
487
 
372
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
+
373
495
  const prompt = buildPrompt(twinContent, twinFilename, currentPrdContent, progressContent);
374
496
 
375
497
  let succeeded = false;
@@ -413,7 +535,8 @@ export async function build({ maxStories = 3, loop = false, maxMinutes = null }
413
535
  console.log(dim(` ${summary(totalBuilt, cycle, Date.now() - startTime)}`));
414
536
  console.log(bar);
415
537
  if (!loop) {
416
- console.log('\nKeep going:\n npx twin-cli build');
538
+ console.log('\n npx twin-cli build # keep building');
539
+ console.log(' npx twin-cli build --loop # build and plan automatically\n');
417
540
  }
418
541
  console.log('');
419
542
  }
package/src/plan.js CHANGED
@@ -204,5 +204,6 @@ export async function plan() {
204
204
  console.log(`---`);
205
205
  console.log(`Wrote ${prdPath}`);
206
206
  console.log(`\nNext step — let your twin build it:`);
207
- console.log(` npx twin-cli build`);
207
+ console.log(` npx twin-cli build # build the next 3 stories`);
208
+ console.log(` npx twin-cli build --loop # build and plan automatically`);
208
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
+ }