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 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
+ }
@@ -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
+ }