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