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 +37 -27
- package/bin/twin.js +14 -7
- package/package.json +1 -1
- package/src/build.js +255 -77
- package/src/generate.js +13 -7
- package/src/init.js +2 -1
- package/src/llm.js +12 -10
- package/src/plan.js +96 -36
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
|
|
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
|
-
|
|
9
|
+
You give an agent a task list. It finishes. It stops. Now you have to decide what comes next.
|
|
12
10
|
|
|
13
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
-
|
|
96
|
-
-
|
|
97
|
-
-
|
|
98
|
-
-
|
|
99
|
-
-
|
|
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
|
-
|
|
107
|
+
Your twin drives the whole cycle.
|
|
106
108
|
|
|
107
|
-
1. **`twin init`** —
|
|
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
|
|
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
|
|
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
|
|
134
|
+
- Reads your twin file for how you think
|
|
131
135
|
- Reads `prd.json` for open stories
|
|
132
|
-
- Picks the next story to build (
|
|
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
|
|
139
|
-
twin build --
|
|
142
|
+
twin build # build the current stories (default: 3)
|
|
143
|
+
twin build --loop # build, plan, build — fully autonomous
|
|
144
|
+
twin build --loop --stories 20 # stop after 20 stories
|
|
145
|
+
twin build --loop --minutes 30 # stop after 30 minutes
|
|
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
|
|
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
|
|
21
|
-
|
|
20
|
+
const loop = process.argv.includes('--loop');
|
|
21
|
+
const storiesFlag = parseFlag('--stories', null);
|
|
22
|
+
const minutesFlag = parseFlag('--minutes', null);
|
|
23
|
+
const maxStories = storiesFlag ? parseInt(storiesFlag, 10) : (loop ? Infinity : 3);
|
|
24
|
+
const maxMinutes = minutesFlag ? parseInt(minutesFlag, 10) : null;
|
|
25
|
+
build({ maxStories, loop, maxMinutes });
|
|
22
26
|
} else if (command === '--help' || command === '-h') {
|
|
23
27
|
console.log(`
|
|
24
|
-
twin -
|
|
28
|
+
twin - your twin builds while you sleep
|
|
25
29
|
|
|
26
30
|
Usage:
|
|
27
|
-
twin init
|
|
28
|
-
twin plan
|
|
29
|
-
twin build [--
|
|
30
|
-
twin --
|
|
31
|
+
twin init Interview yourself, generate your .twin file
|
|
32
|
+
twin plan Your twin decides what to build next
|
|
33
|
+
twin build [--stories N] Build N stories using Claude Code (default: 3)
|
|
34
|
+
twin build --loop Build, plan, build — fully autonomous
|
|
35
|
+
twin build --loop --stories 20 Stop after 20 stories
|
|
36
|
+
twin build --loop --minutes 30 Stop after 30 minutes
|
|
37
|
+
twin --help Show this message
|
|
31
38
|
`);
|
|
32
39
|
} else {
|
|
33
40
|
console.log(`Unknown command: ${command}`);
|
package/package.json
CHANGED
package/src/build.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { readFile, writeFile, readdir } from 'node:fs/promises';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
|
+
import { runPlan } from './plan.js';
|
|
4
5
|
|
|
5
6
|
const COMPLETION_SIGNAL = '<twin>STORY_COMPLETE</twin>';
|
|
6
7
|
const ALL_DONE_SIGNAL = '<twin>ALL_COMPLETE</twin>';
|
|
@@ -17,7 +18,7 @@ async function findTwinFile(cwd) {
|
|
|
17
18
|
const files = await readdir(cwd);
|
|
18
19
|
const twinFiles = files.filter((f) => f.endsWith('.twin'));
|
|
19
20
|
if (twinFiles.length === 0) {
|
|
20
|
-
console.error('No .twin file found. Run `twin init` first.\n');
|
|
21
|
+
console.error('No .twin file found. Run `npx twin-cli init` first.\n');
|
|
21
22
|
process.exit(1);
|
|
22
23
|
}
|
|
23
24
|
return resolve(cwd, twinFiles[0]);
|
|
@@ -29,22 +30,23 @@ function buildPrompt(twinContent, twinFilename, prdContent, progressContent) {
|
|
|
29
30
|
1. **The twin file** (${twinFilename}) — this is the builder's taste, their decision-making DNA. Build the way they would build.
|
|
30
31
|
2. **prd.json** — the product requirements with user stories. Each story has a status: "open", "in_progress", or "done".
|
|
31
32
|
|
|
32
|
-
${progressContent ? '3. **progress.md** — notes from previous build
|
|
33
|
+
${progressContent ? '3. **progress.md** — notes from previous build runs. Read this to understand what was already tried and learned.\n' : ''}
|
|
33
34
|
## Your task
|
|
34
35
|
|
|
35
36
|
1. Read prd.json and find stories that are not "done"
|
|
36
|
-
2. Pick the story YOU think should be built next based on the twin's taste and
|
|
37
|
-
3. Build
|
|
37
|
+
2. Pick ONE story — the single story YOU think should be built next based on the twin's taste and the current state of the codebase
|
|
38
|
+
3. Build that ONE story. Write real, working code. Follow the acceptance criteria.
|
|
38
39
|
4. When the story's acceptance criteria are met, update prd.json: set that story's status to "done" and add "completedAt" with the current ISO timestamp
|
|
39
|
-
5. Append to progress.md what you built, what files changed, and any learnings
|
|
40
|
-
6.
|
|
41
|
-
7.
|
|
40
|
+
5. Append to progress.md what you built, what files changed, and any learnings
|
|
41
|
+
6. Commit your work with a clear message
|
|
42
|
+
7. Output ${COMPLETION_SIGNAL} when you finish the story
|
|
43
|
+
8. If ALL stories in prd.json are "done" after completing yours, output ${ALL_DONE_SIGNAL} instead
|
|
42
44
|
|
|
43
45
|
## Rules
|
|
46
|
+
- Build ONE story per run. Do not start a second story.
|
|
44
47
|
- Build real features, not stubs
|
|
45
48
|
- Follow the taste in the twin file — it tells you how this person builds
|
|
46
|
-
-
|
|
47
|
-
- If you get stuck, note what blocked you in progress.md and move on to a different story
|
|
49
|
+
- If you get stuck, note what blocked you in progress.md and output ${COMPLETION_SIGNAL} anyway
|
|
48
50
|
- Do NOT ask questions. Make decisions based on the twin and the PRD.
|
|
49
51
|
|
|
50
52
|
## Twin file (${twinFilename})
|
|
@@ -56,27 +58,33 @@ ${progressContent ? `\n## progress.md\n${progressContent}` : ''}
|
|
|
56
58
|
`;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
function
|
|
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
|
-
|
|
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
|
-
//
|
|
78
|
-
if (event.type === '
|
|
79
|
-
|
|
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
|
|
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
|
-
|
|
108
|
-
|
|
144
|
+
const idle = Date.now() - lastActivity;
|
|
145
|
+
if (idle > 5_000) {
|
|
146
|
+
showStatus('Working...');
|
|
109
147
|
}
|
|
110
|
-
},
|
|
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
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
|
214
|
-
if (
|
|
215
|
-
|
|
216
|
-
console.log(
|
|
217
|
-
console.log(
|
|
218
|
-
console.log(
|
|
219
|
-
console.log(
|
|
348
|
+
// Check time limit before starting next story
|
|
349
|
+
if (timeLimitMs && (Date.now() - startTime) >= timeLimitMs) {
|
|
350
|
+
const mins = Math.round((Date.now() - startTime) / 60000);
|
|
351
|
+
console.log(bar);
|
|
352
|
+
console.log(bold(' Time limit reached'));
|
|
353
|
+
console.log(dim(` ${summary(totalBuilt, cycle, Date.now() - startTime)}`));
|
|
354
|
+
console.log(bar);
|
|
355
|
+
console.log('');
|
|
220
356
|
break;
|
|
221
357
|
}
|
|
222
358
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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 (
|
|
239
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
let content;
|
|
47
|
+
try {
|
|
48
|
+
content = await callLLM(
|
|
49
|
+
SYSTEM_PROMPT,
|
|
50
|
+
`The builder's name is ${name}.\n\nHere are the interview answers. Generate the .twin file.\n\n${interviewText}`,
|
|
51
|
+
);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.error(`\n${err.message}\n`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
50
56
|
|
|
51
57
|
const filename = `${name.toLowerCase().replace(/[^a-z0-9]/g, '')}.twin`;
|
|
52
58
|
const outPath = resolve(process.cwd(), filename);
|
|
53
59
|
await writeFile(outPath, content, 'utf-8');
|
|
54
|
-
console.log(`Done!
|
|
55
|
-
console.log('
|
|
56
|
-
console.log(' twin plan');
|
|
60
|
+
console.log(`Done! Created ${filename}\n`);
|
|
61
|
+
console.log('Next step — plan what to build:');
|
|
62
|
+
console.log(' npx twin-cli plan');
|
|
57
63
|
}
|
package/src/init.js
CHANGED
|
@@ -25,7 +25,8 @@ export async function init() {
|
|
|
25
25
|
}
|
|
26
26
|
prompter.close();
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
const filename = `${name.toLowerCase().replace(/[^a-z0-9]/g, '')}.twin`;
|
|
29
|
+
console.log(`\nGenerating ${filename}...\n`);
|
|
29
30
|
|
|
30
31
|
const paired = answers.map((a) => `Q: ${a.question}\nA: ${a.answer}`).join('\n\n');
|
|
31
32
|
await generateTwin(name, paired);
|
package/src/llm.js
CHANGED
|
@@ -22,21 +22,24 @@ export async function callLLM(systemPrompt, userMessage) {
|
|
|
22
22
|
|
|
23
23
|
claude.on('close', (code) => {
|
|
24
24
|
if (code !== 0) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
const msg = stderr
|
|
26
|
+
? `Claude Code exited with code ${code}: ${stderr.trim()}`
|
|
27
|
+
: `Claude Code exited with code ${code}.`;
|
|
28
|
+
reject(new Error(msg));
|
|
29
|
+
return;
|
|
28
30
|
}
|
|
29
31
|
resolve(stdout.trim());
|
|
30
32
|
});
|
|
31
33
|
|
|
32
34
|
claude.on('error', (err) => {
|
|
33
35
|
if (err.code === 'ENOENT') {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
reject(new Error(
|
|
37
|
+
'Claude Code is not installed or not in PATH.\n'
|
|
38
|
+
+ 'Install it: https://docs.anthropic.com/en/docs/claude-code'
|
|
39
|
+
));
|
|
40
|
+
return;
|
|
37
41
|
}
|
|
38
|
-
|
|
39
|
-
process.exit(1);
|
|
42
|
+
reject(new Error(`Failed to spawn Claude: ${err.message}`));
|
|
40
43
|
});
|
|
41
44
|
|
|
42
45
|
claude.stdin.write(prompt);
|
|
@@ -44,8 +47,7 @@ export async function callLLM(systemPrompt, userMessage) {
|
|
|
44
47
|
});
|
|
45
48
|
|
|
46
49
|
if (!output) {
|
|
47
|
-
|
|
48
|
-
process.exit(1);
|
|
50
|
+
throw new Error('No content returned from Claude Code.');
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
return output;
|
package/src/plan.js
CHANGED
|
@@ -62,6 +62,13 @@ async function bootstrapProduct() {
|
|
|
62
62
|
return content;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
const PLAN_RETRIES = 2;
|
|
66
|
+
const PLAN_RETRY_DELAY = 15_000; // 15s
|
|
67
|
+
|
|
68
|
+
function sleep(ms) {
|
|
69
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
70
|
+
}
|
|
71
|
+
|
|
65
72
|
function parseLLMJson(raw) {
|
|
66
73
|
// Strip markdown code fences if the LLM wraps its response
|
|
67
74
|
let cleaned = raw.trim();
|
|
@@ -71,36 +78,28 @@ function parseLLMJson(raw) {
|
|
|
71
78
|
return JSON.parse(cleaned);
|
|
72
79
|
}
|
|
73
80
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Headless plan — callable from the build loop.
|
|
83
|
+
* Requires product.md and a .twin file to already exist.
|
|
84
|
+
* Returns the array of NEW stories added (empty if the twin has nothing to add).
|
|
85
|
+
*/
|
|
86
|
+
export async function runPlan(cwd) {
|
|
77
87
|
const files = await readdir(cwd);
|
|
78
88
|
const twinFiles = files.filter((f) => f.endsWith('.twin'));
|
|
79
|
-
if (twinFiles.length === 0)
|
|
80
|
-
|
|
81
|
-
process.exit(1);
|
|
82
|
-
}
|
|
83
|
-
if (twinFiles.length > 1) {
|
|
84
|
-
console.log(`Found multiple .twin files: ${twinFiles.join(', ')}. Using ${twinFiles[0]}.`);
|
|
85
|
-
}
|
|
89
|
+
if (twinFiles.length === 0) return [];
|
|
90
|
+
|
|
86
91
|
const twinPath = resolve(cwd, twinFiles[0]);
|
|
87
92
|
const twin = await readFile(twinPath, 'utf-8');
|
|
88
|
-
console.log(`Using ${twinFiles[0]}\n`);
|
|
89
93
|
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
if (!product) {
|
|
94
|
-
product = await bootstrapProduct();
|
|
95
|
-
}
|
|
94
|
+
const productPath = resolve(cwd, 'product.md');
|
|
95
|
+
const product = await readIfExists(productPath);
|
|
96
|
+
if (!product) return []; // Can't plan without product context
|
|
96
97
|
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
const prdPath = resolve(process.cwd(), 'prd.json');
|
|
98
|
+
const statusPath = resolve(cwd, 'status.md');
|
|
99
|
+
const prdPath = resolve(cwd, 'prd.json');
|
|
100
100
|
const status = await readIfExists(statusPath);
|
|
101
101
|
const existingPrd = await readIfExists(prdPath);
|
|
102
102
|
|
|
103
|
-
// Assemble user message
|
|
104
103
|
let userMessage = `## .twin file\n${twin}\n\n## product.md\n${product}`;
|
|
105
104
|
if (status) {
|
|
106
105
|
userMessage += `\n\n## status.md\n${status}`;
|
|
@@ -110,31 +109,89 @@ export async function plan() {
|
|
|
110
109
|
}
|
|
111
110
|
userMessage += '\n\nGenerate the next 3-5 capabilities as JSON.';
|
|
112
111
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
112
|
+
let raw;
|
|
113
|
+
for (let attempt = 0; attempt <= PLAN_RETRIES; attempt++) {
|
|
114
|
+
try {
|
|
115
|
+
raw = await callLLM(TASK_SYSTEM_PROMPT, userMessage);
|
|
116
|
+
break;
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (attempt < PLAN_RETRIES) {
|
|
119
|
+
console.log(`\x1b[2m API error — retrying plan in ${PLAN_RETRY_DELAY / 1000}s...\x1b[0m`);
|
|
120
|
+
await sleep(PLAN_RETRY_DELAY);
|
|
121
|
+
} else {
|
|
122
|
+
console.error(`Planning failed after ${PLAN_RETRIES + 1} attempts: ${err.message}`);
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
117
127
|
|
|
118
|
-
// Parse structured output
|
|
119
128
|
let prd;
|
|
120
129
|
try {
|
|
121
130
|
prd = parseLLMJson(raw);
|
|
122
|
-
} catch
|
|
123
|
-
|
|
124
|
-
console.error(raw);
|
|
125
|
-
process.exit(1);
|
|
131
|
+
} catch {
|
|
132
|
+
return [];
|
|
126
133
|
}
|
|
127
134
|
|
|
128
|
-
// Derive project name from cwd if LLM didn't provide one
|
|
129
135
|
if (!prd.project) {
|
|
130
|
-
prd.project = basename(
|
|
136
|
+
prd.project = basename(cwd);
|
|
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
|
-
|
|
137
|
-
|
|
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(`\
|
|
206
|
+
console.log(`\nNext step — let your twin build it:`);
|
|
207
|
+
console.log(` npx twin-cli build`);
|
|
148
208
|
}
|