twin-cli 0.1.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 +118 -0
- package/bin/twin.js +36 -0
- package/package.json +26 -0
- package/src/build.js +238 -0
- package/src/generate.js +57 -0
- package/src/init.js +39 -0
- package/src/llm.js +59 -0
- package/src/plan.js +154 -0
- package/src/prompt.js +63 -0
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Twin-Driven Development
|
|
2
|
+
|
|
3
|
+
> **Old TDD**: write the tests first. **New TDD**: write the twin first.
|
|
4
|
+
|
|
5
|
+
A `.twin` file encodes your decision-making DNA — your taste, your biases, your heuristics. Drop it into any project and your AI tools make decisions the way you would.
|
|
6
|
+
|
|
7
|
+
The twin is not a PRD. It's not a project spec. It's **you**.
|
|
8
|
+
|
|
9
|
+
## The Problem
|
|
10
|
+
|
|
11
|
+
AI agents have velocity but no vector. They'll do whatever you tell them — but when you stop telling them, they stop. You're not writing code, but you're always on call. Checking at 2am. Writing the next batch. Going back to sleep.
|
|
12
|
+
|
|
13
|
+
**We call this Vampire Coding.** Technically hands-off. Practically consumed.
|
|
14
|
+
|
|
15
|
+
The root cause: agents don't know what you *would* build next. They don't have your taste.
|
|
16
|
+
|
|
17
|
+
## The Solution
|
|
18
|
+
|
|
19
|
+
Three commands. That's it.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
twin init # encode your taste
|
|
23
|
+
twin plan # your twin decides what to build
|
|
24
|
+
twin build # your twin builds it
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Prerequisites
|
|
28
|
+
|
|
29
|
+
- **Node.js 18+** — [nodejs.org](https://nodejs.org)
|
|
30
|
+
- **OpenRouter API key** — [openrouter.ai/keys](https://openrouter.ai/keys) (for `twin init` and `twin plan`)
|
|
31
|
+
- **Claude Code** — [docs.anthropic.com/en/docs/claude-code](https://docs.anthropic.com/en/docs/claude-code) (for `twin build`)
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# 1. Set your OpenRouter API key
|
|
37
|
+
export OPENROUTER_API_KEY="your-key-here"
|
|
38
|
+
|
|
39
|
+
# 2. Create your twin
|
|
40
|
+
npx twin-cli init
|
|
41
|
+
# → Asks your name, then 5 questions about how you build
|
|
42
|
+
# → Generates dru.twin (or whatever your name is)
|
|
43
|
+
|
|
44
|
+
# 3. Generate your first plan
|
|
45
|
+
npx twin-cli plan
|
|
46
|
+
# → Reads your twin, asks about your product, writes prd.json
|
|
47
|
+
|
|
48
|
+
# 4. Let your twin build it
|
|
49
|
+
npx twin-cli build
|
|
50
|
+
# → Spawns Claude Code in a loop, builds each story, updates prd.json as it goes
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Get an OpenRouter key at [openrouter.ai/keys](https://openrouter.ai/keys).
|
|
54
|
+
|
|
55
|
+
## The Loop: init → plan → build → plan
|
|
56
|
+
|
|
57
|
+
This is the core workflow. Your twin drives the whole cycle:
|
|
58
|
+
|
|
59
|
+
1. **`twin init`** — create your taste profile (once)
|
|
60
|
+
2. **`twin plan`** — your twin generates tasks that match how you'd prioritize
|
|
61
|
+
3. **`twin build`** — your twin builds autonomously, updating prd.json as stories complete
|
|
62
|
+
4. **`twin plan`** again — your twin sees what's done and plans what's next
|
|
63
|
+
|
|
64
|
+
Each iteration, Claude Code starts fresh but reads the files on disk — your twin, the PRD, and a progress log. The files are the memory. Your taste stays consistent across every iteration.
|
|
65
|
+
|
|
66
|
+
## Commands
|
|
67
|
+
|
|
68
|
+
### `twin init`
|
|
69
|
+
Asks your name, then 5 questions about how you build things. Generates `yourname.twin` — your decision-making DNA.
|
|
70
|
+
|
|
71
|
+
### `twin plan`
|
|
72
|
+
Reads your `.twin` file + project context, generates 3-5 atomic capabilities that match your taste.
|
|
73
|
+
|
|
74
|
+
Writes **`prd.json`** — structured JSON with user stories and status tracking.
|
|
75
|
+
|
|
76
|
+
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 existing ones.
|
|
77
|
+
|
|
78
|
+
### `twin build`
|
|
79
|
+
Runs an autonomous build loop using Claude Code. Each iteration:
|
|
80
|
+
- Reads your twin file for taste
|
|
81
|
+
- Reads `prd.json` for open stories
|
|
82
|
+
- Picks the next story to build (the model decides, based on your taste)
|
|
83
|
+
- Builds it, commits, and marks it done in `prd.json`
|
|
84
|
+
- Appends learnings to `progress.md`
|
|
85
|
+
- Repeats until all stories are done or max iterations hit
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
twin build # default: 5 iterations
|
|
89
|
+
twin build --max-iterations 10 # custom limit
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Requires [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed and available in your PATH.
|
|
93
|
+
|
|
94
|
+
## What Goes in a `.twin` File
|
|
95
|
+
|
|
96
|
+
- **Execution Bias** — do you plan or build first? Ship ugly or wait for polish?
|
|
97
|
+
- **Quality Compass** — how do you define "done"? What does "good" mean to you?
|
|
98
|
+
- **Decision-Making Style** — how do you get unstuck? How do you evaluate tradeoffs?
|
|
99
|
+
- **Strongly Held Beliefs** — what do you believe that others would disagree with?
|
|
100
|
+
- **Anti-Patterns** — what do you refuse to do?
|
|
101
|
+
|
|
102
|
+
## What Does NOT Go in a `.twin` File
|
|
103
|
+
|
|
104
|
+
- Your product's target user (that's a product doc)
|
|
105
|
+
- Your tech stack (that's a project config)
|
|
106
|
+
- Your roadmap (that's a PRD)
|
|
107
|
+
|
|
108
|
+
**The twin encodes how you think. Project files encode what you're building. The twin is the founder. The project files are the company.**
|
|
109
|
+
|
|
110
|
+
## Philosophy
|
|
111
|
+
|
|
112
|
+
This project follows the skateboard-to-car model of iterative delivery.
|
|
113
|
+
|
|
114
|
+
What comes next: `twin tweak` for natural language updates, `twin share` for publishing your taste publicly.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
package/bin/twin.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { init } from '../src/init.js';
|
|
4
|
+
import { plan } from '../src/plan.js';
|
|
5
|
+
import { build } from '../src/build.js';
|
|
6
|
+
|
|
7
|
+
const command = process.argv[2];
|
|
8
|
+
|
|
9
|
+
function parseFlag(flag, defaultValue) {
|
|
10
|
+
const idx = process.argv.indexOf(flag);
|
|
11
|
+
if (idx === -1) return defaultValue;
|
|
12
|
+
return process.argv[idx + 1];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!command || command === 'init') {
|
|
16
|
+
init();
|
|
17
|
+
} else if (command === 'plan') {
|
|
18
|
+
plan();
|
|
19
|
+
} else if (command === 'build') {
|
|
20
|
+
const maxIterations = parseInt(parseFlag('--max-iterations', '5'), 10);
|
|
21
|
+
build(maxIterations);
|
|
22
|
+
} else if (command === '--help' || command === '-h') {
|
|
23
|
+
console.log(`
|
|
24
|
+
twin - encode your decision-making DNA
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
twin init Interview yourself, generate your .twin file
|
|
28
|
+
twin plan Generate tasks that match your taste
|
|
29
|
+
twin build [--max-iterations N] Build autonomously using Claude Code (default: 5)
|
|
30
|
+
twin --help Show this message
|
|
31
|
+
`);
|
|
32
|
+
} else {
|
|
33
|
+
console.log(`Unknown command: ${command}`);
|
|
34
|
+
console.log(`Run "twin --help" for usage.`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "twin-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Encode your decision-making DNA into a .twin file. The .env of taste.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"twin": "./bin/twin.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"twin",
|
|
16
|
+
"taste",
|
|
17
|
+
"ai",
|
|
18
|
+
"agent",
|
|
19
|
+
"decision-making",
|
|
20
|
+
"vibe-coding"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/build.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFile, writeFile, readdir } from 'node:fs/promises';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const COMPLETION_SIGNAL = '<twin>STORY_COMPLETE</twin>';
|
|
6
|
+
const ALL_DONE_SIGNAL = '<twin>ALL_COMPLETE</twin>';
|
|
7
|
+
|
|
8
|
+
async function readIfExists(path) {
|
|
9
|
+
try {
|
|
10
|
+
return await readFile(path, 'utf-8');
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function findTwinFile(cwd) {
|
|
17
|
+
const files = await readdir(cwd);
|
|
18
|
+
const twinFiles = files.filter((f) => f.endsWith('.twin'));
|
|
19
|
+
if (twinFiles.length === 0) {
|
|
20
|
+
console.error('No .twin file found. Run `twin init` first.\n');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
return resolve(cwd, twinFiles[0]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildPrompt(twinContent, twinFilename, prdContent, progressContent) {
|
|
27
|
+
return `You are an autonomous builder working on a software project. You have two sources of truth:
|
|
28
|
+
|
|
29
|
+
1. **The twin file** (${twinFilename}) — this is the builder's taste, their decision-making DNA. Build the way they would build.
|
|
30
|
+
2. **prd.json** — the product requirements with user stories. Each story has a status: "open", "in_progress", or "done".
|
|
31
|
+
|
|
32
|
+
${progressContent ? '3. **progress.md** — notes from previous build iterations. Read this to understand what was already tried and learned.\n' : ''}
|
|
33
|
+
## Your task
|
|
34
|
+
|
|
35
|
+
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.
|
|
38
|
+
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
|
+
|
|
43
|
+
## Rules
|
|
44
|
+
- Build real features, not stubs
|
|
45
|
+
- 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
|
|
48
|
+
- Do NOT ask questions. Make decisions based on the twin and the PRD.
|
|
49
|
+
|
|
50
|
+
## Twin file (${twinFilename})
|
|
51
|
+
${twinContent}
|
|
52
|
+
|
|
53
|
+
## prd.json
|
|
54
|
+
${prdContent}
|
|
55
|
+
${progressContent ? `\n## progress.md\n${progressContent}` : ''}
|
|
56
|
+
`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function extractTextFromEvent(jsonLine) {
|
|
60
|
+
try {
|
|
61
|
+
const event = JSON.parse(jsonLine);
|
|
62
|
+
|
|
63
|
+
// Text delta from assistant message streaming
|
|
64
|
+
if (event.type === 'assistant' && event.message?.content) {
|
|
65
|
+
// Full message content (final)
|
|
66
|
+
return event.message.content
|
|
67
|
+
.filter((block) => block.type === 'text')
|
|
68
|
+
.map((block) => block.text)
|
|
69
|
+
.join('');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Partial streaming text delta
|
|
73
|
+
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
|
|
74
|
+
return event.delta.text;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Result message with text
|
|
78
|
+
if (event.type === 'result' && event.result) {
|
|
79
|
+
return null; // Already captured via deltas
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// Not valid JSON or unexpected shape — skip
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function runIteration(prompt, cwd) {
|
|
88
|
+
return new Promise((resolvePromise) => {
|
|
89
|
+
const claude = spawn('claude', [
|
|
90
|
+
'--print',
|
|
91
|
+
'--dangerously-skip-permissions',
|
|
92
|
+
'--output-format', 'stream-json',
|
|
93
|
+
], {
|
|
94
|
+
cwd,
|
|
95
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
96
|
+
env: { ...process.env },
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
let output = '';
|
|
100
|
+
let buffer = '';
|
|
101
|
+
let hasReceivedText = false;
|
|
102
|
+
|
|
103
|
+
// Heartbeat timer — show elapsed time while waiting
|
|
104
|
+
const startTime = Date.now();
|
|
105
|
+
const heartbeat = setInterval(() => {
|
|
106
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
107
|
+
if (!hasReceivedText) {
|
|
108
|
+
process.stdout.write(`\rWorking... (${elapsed}s)`);
|
|
109
|
+
}
|
|
110
|
+
}, 10_000);
|
|
111
|
+
|
|
112
|
+
claude.stdout.on('data', (chunk) => {
|
|
113
|
+
buffer += chunk.toString();
|
|
114
|
+
const lines = buffer.split('\n');
|
|
115
|
+
buffer = lines.pop(); // Keep incomplete last line in buffer
|
|
116
|
+
|
|
117
|
+
for (const line of lines) {
|
|
118
|
+
if (!line.trim()) continue;
|
|
119
|
+
const text = extractTextFromEvent(line);
|
|
120
|
+
if (text) {
|
|
121
|
+
if (!hasReceivedText) {
|
|
122
|
+
// Clear the heartbeat line on first real output
|
|
123
|
+
process.stdout.write('\r\x1b[K');
|
|
124
|
+
hasReceivedText = true;
|
|
125
|
+
}
|
|
126
|
+
process.stdout.write(text);
|
|
127
|
+
output += text;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
claude.stderr.on('data', (chunk) => {
|
|
133
|
+
process.stderr.write(chunk);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
claude.on('close', (code) => {
|
|
137
|
+
clearInterval(heartbeat);
|
|
138
|
+
// Process any remaining buffer
|
|
139
|
+
if (buffer.trim()) {
|
|
140
|
+
const text = extractTextFromEvent(buffer.trim());
|
|
141
|
+
if (text) {
|
|
142
|
+
process.stdout.write(text);
|
|
143
|
+
output += text;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!hasReceivedText) {
|
|
147
|
+
process.stdout.write('\r\x1b[K'); // Clear heartbeat line
|
|
148
|
+
}
|
|
149
|
+
resolvePromise({ output, code });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
claude.on('error', (err) => {
|
|
153
|
+
clearInterval(heartbeat);
|
|
154
|
+
if (err.code === 'ENOENT') {
|
|
155
|
+
console.error('\nClaude Code is not installed or not in PATH.');
|
|
156
|
+
console.error('Install it: https://docs.anthropic.com/en/docs/claude-code\n');
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
console.error(`\nFailed to spawn Claude: ${err.message}\n`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
claude.stdin.write(prompt);
|
|
164
|
+
claude.stdin.end();
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function build(maxIterations = 5) {
|
|
169
|
+
const cwd = process.cwd();
|
|
170
|
+
|
|
171
|
+
// Find twin file
|
|
172
|
+
const twinPath = await findTwinFile(cwd);
|
|
173
|
+
const twinFilename = twinPath.split('/').pop();
|
|
174
|
+
const twinContent = await readFile(twinPath, 'utf-8');
|
|
175
|
+
|
|
176
|
+
// Read prd.json — required
|
|
177
|
+
const prdPath = resolve(cwd, 'prd.json');
|
|
178
|
+
const prdContent = await readIfExists(prdPath);
|
|
179
|
+
if (!prdContent) {
|
|
180
|
+
console.error('No prd.json found. Run `twin plan` first.\n');
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Check if there are open stories
|
|
185
|
+
const prd = JSON.parse(prdContent);
|
|
186
|
+
const openStories = prd.userStories.filter((s) => s.status !== 'done');
|
|
187
|
+
if (openStories.length === 0) {
|
|
188
|
+
console.log('All stories in prd.json are already done. Run `twin plan` to generate more.\n');
|
|
189
|
+
process.exit(0);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
console.log(`\n--- twin build ---`);
|
|
193
|
+
console.log(`Using ${twinFilename}`);
|
|
194
|
+
console.log(`${openStories.length} stories remaining in prd.json`);
|
|
195
|
+
console.log(`Max iterations: ${maxIterations}\n`);
|
|
196
|
+
|
|
197
|
+
for (let i = 1; i <= maxIterations; i++) {
|
|
198
|
+
console.log(`\n${'='.repeat(60)}`);
|
|
199
|
+
console.log(` Iteration ${i} of ${maxIterations}`);
|
|
200
|
+
console.log(`${'='.repeat(60)}\n`);
|
|
201
|
+
|
|
202
|
+
// Re-read files each iteration (they may have been updated)
|
|
203
|
+
const currentPrd = await readFile(prdPath, 'utf-8');
|
|
204
|
+
const progressContent = await readIfExists(resolve(cwd, 'progress.md'));
|
|
205
|
+
|
|
206
|
+
const prompt = buildPrompt(twinContent, twinFilename, currentPrd, progressContent);
|
|
207
|
+
const { output, code } = await runIteration(prompt, cwd);
|
|
208
|
+
|
|
209
|
+
if (code !== 0) {
|
|
210
|
+
console.log(`\nClaude exited with code ${code}. Continuing to next iteration...\n`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Check completion signals
|
|
214
|
+
if (output.includes(ALL_DONE_SIGNAL)) {
|
|
215
|
+
console.log(`\n${'='.repeat(60)}`);
|
|
216
|
+
console.log(' All stories complete!');
|
|
217
|
+
console.log(`${'='.repeat(60)}\n`);
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (output.includes(COMPLETION_SIGNAL)) {
|
|
222
|
+
// Re-read PRD to check remaining stories
|
|
223
|
+
const updatedPrd = JSON.parse(await readFile(prdPath, 'utf-8'));
|
|
224
|
+
const remaining = updatedPrd.userStories.filter((s) => s.status !== 'done');
|
|
225
|
+
if (remaining.length === 0) {
|
|
226
|
+
console.log(`\n${'='.repeat(60)}`);
|
|
227
|
+
console.log(' All stories complete!');
|
|
228
|
+
console.log(`${'='.repeat(60)}\n`);
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
console.log(`\nStory complete. ${remaining.length} remaining.\n`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (i === maxIterations) {
|
|
235
|
+
console.log(`\nReached max iterations (${maxIterations}). Run \`twin build\` again to continue.\n`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
package/src/generate.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { callLLM } from './llm.js';
|
|
4
|
+
|
|
5
|
+
const SYSTEM_PROMPT = `You are a taste interpreter. You read someone's answers to 5 questions about how they build things, and you produce a .twin file — a Markdown document that encodes their decision-making DNA.
|
|
6
|
+
|
|
7
|
+
The .twin file has these sections:
|
|
8
|
+
|
|
9
|
+
# Twin — [Name or "Anonymous"]
|
|
10
|
+
|
|
11
|
+
## Execution Bias
|
|
12
|
+
- Speed vs polish preference
|
|
13
|
+
- How they start things
|
|
14
|
+
- Shipping philosophy
|
|
15
|
+
|
|
16
|
+
## Quality Compass
|
|
17
|
+
- How they define "done"
|
|
18
|
+
- What "good" means to them
|
|
19
|
+
- Standards they hold
|
|
20
|
+
|
|
21
|
+
## Decision-Making Style
|
|
22
|
+
- How they get unstuck
|
|
23
|
+
- How they evaluate tradeoffs
|
|
24
|
+
- Instincts and heuristics
|
|
25
|
+
|
|
26
|
+
## Strongly Held Beliefs
|
|
27
|
+
- Contrarian views
|
|
28
|
+
- Things they refuse to do
|
|
29
|
+
- Non-negotiable principles
|
|
30
|
+
|
|
31
|
+
## Anti-Patterns
|
|
32
|
+
- Things to avoid
|
|
33
|
+
- Red flags they watch for
|
|
34
|
+
- Patterns they reject
|
|
35
|
+
|
|
36
|
+
Rules:
|
|
37
|
+
- Write in second person ("You prefer...", "You believe...")
|
|
38
|
+
- Be specific and opinionated, not generic
|
|
39
|
+
- Use their actual words and phrasings when possible
|
|
40
|
+
- Each bullet should be a concrete, actionable heuristic — not a vague platitude
|
|
41
|
+
- If they didn't give enough signal for a section, write fewer bullets rather than making things up
|
|
42
|
+
- Keep it under 80 lines total
|
|
43
|
+
- No preamble, no explanation — just the .twin file content`;
|
|
44
|
+
|
|
45
|
+
export async function generateTwin(apiKey, name, interviewText) {
|
|
46
|
+
const content = await callLLM(
|
|
47
|
+
apiKey,
|
|
48
|
+
SYSTEM_PROMPT,
|
|
49
|
+
`The builder's name is ${name}.\n\nHere are the interview answers. Generate the .twin file.\n\n${interviewText}`,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const filename = `${name.toLowerCase().replace(/[^a-z0-9]/g, '')}.twin`;
|
|
53
|
+
const outPath = resolve(process.cwd(), filename);
|
|
54
|
+
await writeFile(outPath, content, 'utf-8');
|
|
55
|
+
console.log(`Done! Your twin file is at: ${outPath}`);
|
|
56
|
+
console.log('\nDrop this file into any project and your AI tools will know your taste.');
|
|
57
|
+
}
|
package/src/init.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { requireKey, checkKey } from './llm.js';
|
|
2
|
+
import { createPrompter } from './prompt.js';
|
|
3
|
+
import { generateTwin } from './generate.js';
|
|
4
|
+
|
|
5
|
+
const QUESTIONS = [
|
|
6
|
+
"When you start something new, do you plan first or build first?",
|
|
7
|
+
"Do you ship something ugly that works, or wait until it's polished?",
|
|
8
|
+
"How do you know when something is done?",
|
|
9
|
+
"Describe something you built or created that you're proud of. What made it good?",
|
|
10
|
+
"What do you believe about building things that most people would disagree with?",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export async function init() {
|
|
14
|
+
const key = requireKey();
|
|
15
|
+
|
|
16
|
+
console.log('\nChecking API connection...');
|
|
17
|
+
await checkKey(key);
|
|
18
|
+
console.log('Connected.\n');
|
|
19
|
+
|
|
20
|
+
console.log('--- twin init ---');
|
|
21
|
+
console.log('Answer a few questions. Say as much or as little as you want.');
|
|
22
|
+
console.log('Your answers will be used to generate your .twin file.\n');
|
|
23
|
+
|
|
24
|
+
const prompter = createPrompter();
|
|
25
|
+
|
|
26
|
+
const name = await prompter.ask('What should we call you? (First name is fine.)');
|
|
27
|
+
|
|
28
|
+
const answers = [];
|
|
29
|
+
for (const q of QUESTIONS) {
|
|
30
|
+
const answer = await prompter.ask(q);
|
|
31
|
+
answers.push({ question: q, answer });
|
|
32
|
+
}
|
|
33
|
+
prompter.close();
|
|
34
|
+
|
|
35
|
+
console.log('\nGenerating your .twin file...\n');
|
|
36
|
+
|
|
37
|
+
const paired = answers.map((a) => `Q: ${a.question}\nA: ${a.answer}`).join('\n\n');
|
|
38
|
+
await generateTwin(key, name, paired);
|
|
39
|
+
}
|
package/src/llm.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export function requireKey() {
|
|
2
|
+
const key = process.env.OPENROUTER_API_KEY;
|
|
3
|
+
if (!key) {
|
|
4
|
+
console.log('\nSet your OpenRouter API key first:');
|
|
5
|
+
console.log(' export OPENROUTER_API_KEY="your-key-here"');
|
|
6
|
+
console.log('\nGet one at https://openrouter.ai/keys\n');
|
|
7
|
+
process.exit(1);
|
|
8
|
+
}
|
|
9
|
+
return key;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function checkKey(key) {
|
|
13
|
+
try {
|
|
14
|
+
const res = await fetch('https://openrouter.ai/api/v1/models', {
|
|
15
|
+
headers: { 'Authorization': `Bearer ${key}` },
|
|
16
|
+
});
|
|
17
|
+
if (!res.ok) {
|
|
18
|
+
console.error(`\nAPI key check failed (${res.status}). Make sure your OPENROUTER_API_KEY is valid.`);
|
|
19
|
+
console.log('Get one at https://openrouter.ai/keys\n');
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
} catch (e) {
|
|
23
|
+
console.error('\nCould not connect to OpenRouter. Check your internet connection.\n');
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function callLLM(apiKey, systemPrompt, userMessage) {
|
|
29
|
+
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: {
|
|
32
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
33
|
+
'Content-Type': 'application/json',
|
|
34
|
+
},
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
model: 'google/gemini-3-flash-preview',
|
|
37
|
+
messages: [
|
|
38
|
+
{ role: 'system', content: systemPrompt },
|
|
39
|
+
{ role: 'user', content: userMessage },
|
|
40
|
+
],
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const err = await res.text();
|
|
46
|
+
console.error(`OpenRouter API error (${res.status}): ${err}`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const data = await res.json();
|
|
51
|
+
const content = data.choices?.[0]?.message?.content;
|
|
52
|
+
|
|
53
|
+
if (!content) {
|
|
54
|
+
console.error('No content returned from API.');
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return content;
|
|
59
|
+
}
|
package/src/plan.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { readFile, writeFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { resolve, basename } from 'node:path';
|
|
3
|
+
import { requireKey, checkKey, callLLM } from './llm.js';
|
|
4
|
+
import { createPrompter } from './prompt.js';
|
|
5
|
+
|
|
6
|
+
const TASK_SYSTEM_PROMPT = `You are a taste-aware product planner. You receive:
|
|
7
|
+
1. A .twin file — the builder's decision-making DNA (how they think, what they value)
|
|
8
|
+
2. A product.md — what they're building, for whom, and where it stands
|
|
9
|
+
3. Optionally, a status.md — recent progress
|
|
10
|
+
4. Optionally, an existing prd.json — stories already planned (avoid duplicating these)
|
|
11
|
+
|
|
12
|
+
Your job: generate 3-5 atomic capabilities — things a user can DO after they're built. Not stubs, not refactors, not "set up X." Real, demoable features.
|
|
13
|
+
|
|
14
|
+
Rules:
|
|
15
|
+
- Match the builder's taste. If they ship fast, suggest quick wins. If they want polish, suggest completeness.
|
|
16
|
+
- Order by priority — most impactful first
|
|
17
|
+
- Use plain language, no jargon
|
|
18
|
+
- Do NOT duplicate anything already in the existing tasks
|
|
19
|
+
|
|
20
|
+
You MUST respond with valid JSON only. No markdown, no code fences, no explanation. Just the JSON object.
|
|
21
|
+
|
|
22
|
+
Schema:
|
|
23
|
+
{
|
|
24
|
+
"project": "short project name",
|
|
25
|
+
"description": "one-line project description",
|
|
26
|
+
"userStories": [
|
|
27
|
+
{
|
|
28
|
+
"id": "US-001",
|
|
29
|
+
"title": "short capability title",
|
|
30
|
+
"description": "As a [user], I can [do thing] so that [value].",
|
|
31
|
+
"acceptanceCriteria": ["criterion 1", "criterion 2"],
|
|
32
|
+
"status": "open",
|
|
33
|
+
"whyNow": "one sentence: why this is the right next thing"
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
Every story MUST have "status": "open". Do not include priority numbers — ordering in the array IS the priority.`;
|
|
39
|
+
|
|
40
|
+
async function readIfExists(path) {
|
|
41
|
+
try {
|
|
42
|
+
return await readFile(path, 'utf-8');
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function bootstrapProduct() {
|
|
49
|
+
console.log('\nNo product.md found. Let\'s set up your project context.\n');
|
|
50
|
+
|
|
51
|
+
const prompter = createPrompter();
|
|
52
|
+
|
|
53
|
+
const what = await prompter.ask('What are you building?');
|
|
54
|
+
const who = await prompter.ask('Who is it for?');
|
|
55
|
+
prompter.close();
|
|
56
|
+
|
|
57
|
+
const content = `# Product\n\n## What\n${what}\n\n## Who\n${who}\n`;
|
|
58
|
+
|
|
59
|
+
const outPath = resolve(process.cwd(), 'product.md');
|
|
60
|
+
await writeFile(outPath, content, 'utf-8');
|
|
61
|
+
console.log(`\nWrote ${outPath}`);
|
|
62
|
+
return content;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseLLMJson(raw) {
|
|
66
|
+
// Strip markdown code fences if the LLM wraps its response
|
|
67
|
+
let cleaned = raw.trim();
|
|
68
|
+
if (cleaned.startsWith('```')) {
|
|
69
|
+
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
|
|
70
|
+
}
|
|
71
|
+
return JSON.parse(cleaned);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function plan() {
|
|
75
|
+
const key = requireKey();
|
|
76
|
+
|
|
77
|
+
console.log('\nChecking API connection...');
|
|
78
|
+
await checkKey(key);
|
|
79
|
+
console.log('Connected.\n');
|
|
80
|
+
|
|
81
|
+
// Find *.twin file — required
|
|
82
|
+
const cwd = process.cwd();
|
|
83
|
+
const files = await readdir(cwd);
|
|
84
|
+
const twinFiles = files.filter((f) => f.endsWith('.twin'));
|
|
85
|
+
if (twinFiles.length === 0) {
|
|
86
|
+
console.error('No .twin file found. Run `twin init` first.\n');
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
if (twinFiles.length > 1) {
|
|
90
|
+
console.log(`Found multiple .twin files: ${twinFiles.join(', ')}. Using ${twinFiles[0]}.`);
|
|
91
|
+
}
|
|
92
|
+
const twinPath = resolve(cwd, twinFiles[0]);
|
|
93
|
+
const twin = await readFile(twinPath, 'utf-8');
|
|
94
|
+
console.log(`Using ${twinFiles[0]}\n`);
|
|
95
|
+
|
|
96
|
+
// Read or bootstrap product.md
|
|
97
|
+
const productPath = resolve(process.cwd(), 'product.md');
|
|
98
|
+
let product = await readIfExists(productPath);
|
|
99
|
+
if (!product) {
|
|
100
|
+
product = await bootstrapProduct();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Read optional context files
|
|
104
|
+
const statusPath = resolve(process.cwd(), 'status.md');
|
|
105
|
+
const prdPath = resolve(process.cwd(), 'prd.json');
|
|
106
|
+
const status = await readIfExists(statusPath);
|
|
107
|
+
const existingPrd = await readIfExists(prdPath);
|
|
108
|
+
|
|
109
|
+
// Assemble user message
|
|
110
|
+
let userMessage = `## .twin file\n${twin}\n\n## product.md\n${product}`;
|
|
111
|
+
if (status) {
|
|
112
|
+
userMessage += `\n\n## status.md\n${status}`;
|
|
113
|
+
}
|
|
114
|
+
if (existingPrd) {
|
|
115
|
+
userMessage += `\n\n## Existing prd.json (do NOT duplicate these stories)\n${existingPrd}`;
|
|
116
|
+
}
|
|
117
|
+
userMessage += '\n\nGenerate the next 3-5 capabilities as JSON.';
|
|
118
|
+
|
|
119
|
+
console.log('--- twin plan ---');
|
|
120
|
+
console.log('Generating tasks that match your taste...\n');
|
|
121
|
+
|
|
122
|
+
const raw = await callLLM(key, TASK_SYSTEM_PROMPT, userMessage);
|
|
123
|
+
|
|
124
|
+
// Parse structured output
|
|
125
|
+
let prd;
|
|
126
|
+
try {
|
|
127
|
+
prd = parseLLMJson(raw);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
console.error('Failed to parse LLM response as JSON. Raw output:\n');
|
|
130
|
+
console.error(raw);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Derive project name from cwd if LLM didn't provide one
|
|
135
|
+
if (!prd.project) {
|
|
136
|
+
prd.project = basename(process.cwd());
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Write prd.json
|
|
140
|
+
await writeFile(prdPath, JSON.stringify(prd, null, 2) + '\n', 'utf-8');
|
|
141
|
+
|
|
142
|
+
// Print stories to console
|
|
143
|
+
for (const story of prd.userStories) {
|
|
144
|
+
console.log(`${story.id}. ${story.title}`);
|
|
145
|
+
console.log(` ${story.description}`);
|
|
146
|
+
for (const ac of story.acceptanceCriteria) {
|
|
147
|
+
console.log(` - ${ac}`);
|
|
148
|
+
}
|
|
149
|
+
console.log('');
|
|
150
|
+
}
|
|
151
|
+
console.log(`---`);
|
|
152
|
+
console.log(`Wrote ${prdPath}`);
|
|
153
|
+
console.log(`\nRun \`twin build\` to start building.`);
|
|
154
|
+
}
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import * as readline from 'node:readline';
|
|
2
|
+
|
|
3
|
+
const INPUT_TIMEOUT_MS = 1500;
|
|
4
|
+
|
|
5
|
+
export function createPrompter() {
|
|
6
|
+
const rl = readline.createInterface({
|
|
7
|
+
input: process.stdin,
|
|
8
|
+
output: process.stdout,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
async function ask(question) {
|
|
12
|
+
console.log(`\n${question}`);
|
|
13
|
+
|
|
14
|
+
const lines = [];
|
|
15
|
+
let timer = null;
|
|
16
|
+
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const finish = () => {
|
|
19
|
+
rl.removeListener('line', onLine);
|
|
20
|
+
resolve(lines.join(' ').trim());
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const resetTimer = () => {
|
|
24
|
+
if (timer) clearTimeout(timer);
|
|
25
|
+
timer = setTimeout(finish, INPUT_TIMEOUT_MS);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const onLine = (line) => {
|
|
29
|
+
const trimmed = line.trim();
|
|
30
|
+
if (trimmed !== '') {
|
|
31
|
+
lines.push(trimmed);
|
|
32
|
+
}
|
|
33
|
+
// If we have content, start/reset the timeout
|
|
34
|
+
if (lines.length > 0) {
|
|
35
|
+
resetTimer();
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
process.stdout.write('> ');
|
|
40
|
+
rl.on('line', onLine);
|
|
41
|
+
|
|
42
|
+
// For single-line answers (keyboard), also resolve on first Enter after content
|
|
43
|
+
// But give a moment for more lines to arrive (paste/voice)
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Drain any leftover input between questions
|
|
48
|
+
async function drain() {
|
|
49
|
+
return new Promise((resolve) => setTimeout(resolve, 100));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function askAndDrain(question) {
|
|
53
|
+
const answer = await ask(question);
|
|
54
|
+
await drain();
|
|
55
|
+
return answer;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function close() {
|
|
59
|
+
rl.close();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { ask: askAndDrain, close };
|
|
63
|
+
}
|