@drawcall/create 0.0.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 +249 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +18 -0
- package/dist/command.d.ts +16 -0
- package/dist/command.js +123 -0
- package/dist/constants.d.ts +26 -0
- package/dist/constants.js +94 -0
- package/dist/create.d.ts +35 -0
- package/dist/create.js +348 -0
- package/dist/harness.d.ts +13 -0
- package/dist/harness.js +81 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/progress-log.d.ts +31 -0
- package/dist/progress-log.js +95 -0
- package/dist/prompts.d.ts +6 -0
- package/dist/prompts.js +138 -0
- package/dist/scaffold.d.ts +15 -0
- package/dist/scaffold.js +220 -0
- package/dist/subprocess.d.ts +21 -0
- package/dist/subprocess.js +117 -0
- package/package.json +47 -0
package/dist/create.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { basename, join, resolve } from "node:path";
|
|
4
|
+
import { CliError, DEFAULT_HARNESS_TIMEOUT_MS, GOAL_FILE, MAX_BUILD_TURNS, PLAN_FILE, SESSION_LOG_FILE } from "./constants.js";
|
|
5
|
+
import { runHarnessTurn, selectHarness } from "./harness.js";
|
|
6
|
+
import { createSubprocessRunner, isCommandAvailable } from "./subprocess.js";
|
|
7
|
+
import { buildSurveyAssetsPrompt, buildBuildPrompt, buildGoalPrompt, buildPlanPrompt, buildSurveyTechnologyPrompt, buildTemplatePrompt } from "./prompts.js";
|
|
8
|
+
import { ProgressLog } from "./progress-log.js";
|
|
9
|
+
import { commitAll, ensureBaseProjectDirectories, ensureNpmrc, ensureProjectGitignore, ensureStateReadme, generateProjectName, initGitRepo, initNpmProject, installDependencies, installSkills } from "./scaffold.js";
|
|
10
|
+
// A harness turn fails for two very different reasons. A timeout means the turn ran too long — the
|
|
11
|
+
// model is grinding, not flaky — so re-running it would just burn the same hours again. Every other
|
|
12
|
+
// non-zero exit is, in practice, a transient provider blip: an overloaded API, a gateway 5xx, a
|
|
13
|
+
// dropped/empty stream. One such blip used to abort an entire multi-hour run at whatever turn it
|
|
14
|
+
// landed on. A turn is safe to re-run — think-turns rewrite their output file, build-turns re-read
|
|
15
|
+
// the repo state and continue the same plan step — so a non-timeout failure retries a few times
|
|
16
|
+
// with a short, lengthening backoff before it propagates. A clean exit returns on the first try.
|
|
17
|
+
const HARNESS_TURN_ATTEMPTS = 3;
|
|
18
|
+
const RETRY_BACKOFF_MS = 15_000;
|
|
19
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
|
+
async function runTurn(harnessRunner, prompt) {
|
|
21
|
+
let result = await runHarnessTurn(harnessRunner, prompt);
|
|
22
|
+
for (let attempt = 2; attempt <= HARNESS_TURN_ATTEMPTS && result.exitCode !== 0 && !result.timedOut; attempt += 1) {
|
|
23
|
+
await delay(RETRY_BACKOFF_MS * (attempt - 1));
|
|
24
|
+
result = await runHarnessTurn(harnessRunner, prompt);
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
export async function createProject(prompt, options = {}) {
|
|
29
|
+
const { env = process.env, commandExists = isCommandAvailable } = options;
|
|
30
|
+
const stage = options.stage ?? "full";
|
|
31
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
32
|
+
const harness = await selectHarness(options.harness, env, commandExists);
|
|
33
|
+
const maxTurns = options.maxTurns ?? MAX_BUILD_TURNS;
|
|
34
|
+
const startedAt = Date.now();
|
|
35
|
+
if (!(await commandExists("git", env))) {
|
|
36
|
+
throw new CliError("git is required but was not found on PATH");
|
|
37
|
+
}
|
|
38
|
+
// "scaffold" and "full" create a fresh project directory. The other stages
|
|
39
|
+
// continue an existing repo at the current working directory.
|
|
40
|
+
const createsProjectDir = stage === "scaffold" || stage === "full";
|
|
41
|
+
const projectName = createsProjectDir
|
|
42
|
+
? (options.projectName ?? generateProjectName())
|
|
43
|
+
: basename(cwd);
|
|
44
|
+
const projectDir = createsProjectDir ? resolve(cwd, projectName) : cwd;
|
|
45
|
+
if (createsProjectDir && existsSync(projectDir)) {
|
|
46
|
+
throw new CliError(`project directory already exists: ${projectDir}`);
|
|
47
|
+
}
|
|
48
|
+
if (!createsProjectDir && !existsSync(join(projectDir, ".git"))) {
|
|
49
|
+
throw new CliError(`the "${stage}" stage expects an existing git repo at ${projectDir}; run the scaffold stage first`);
|
|
50
|
+
}
|
|
51
|
+
// Capture every subprocess into the session-log so the terminal can stay a clean
|
|
52
|
+
// spinner. A caller-supplied runner opts out of both (it handles its own I/O).
|
|
53
|
+
const logFile = join(projectDir, SESSION_LOG_FILE);
|
|
54
|
+
const runner = options.runner ?? createSubprocessRunner({ env, logFile });
|
|
55
|
+
const progressLog = new ProgressLog({ logFile: options.runner ? undefined : logFile });
|
|
56
|
+
if (createsProjectDir) {
|
|
57
|
+
await mkdir(projectDir);
|
|
58
|
+
}
|
|
59
|
+
const harnessRunner = {
|
|
60
|
+
harness,
|
|
61
|
+
harnessArgs: options.harnessArgs ?? [],
|
|
62
|
+
timeoutMs: options.harnessTimeoutMs ?? DEFAULT_HARNESS_TIMEOUT_MS,
|
|
63
|
+
cwd: projectDir,
|
|
64
|
+
runner
|
|
65
|
+
};
|
|
66
|
+
const resultMaxTurns = stage === "full" ? maxTurns : stage === "build" ? 1 : 0;
|
|
67
|
+
progressLog.begin(projectName, harness, projectDir);
|
|
68
|
+
try {
|
|
69
|
+
if (createsProjectDir) {
|
|
70
|
+
await initGitRepo(projectDir, runner);
|
|
71
|
+
}
|
|
72
|
+
const result = await runStages(stage, harnessRunner, progressLog, prompt, projectName, {
|
|
73
|
+
maxTurns,
|
|
74
|
+
skipTemplate: options.skipTemplate === true
|
|
75
|
+
});
|
|
76
|
+
return { ...result, maxTurns: resultMaxTurns, durationMs: Date.now() - startedAt };
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
progressLog.stop();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Run the requested stage plus everything it implies, in pipeline order. */
|
|
83
|
+
async function runStages(stage, harnessRunner, progressLog, prompt, projectName, options) {
|
|
84
|
+
const base = { projectDir: harnessRunner.cwd, projectName, harness: harnessRunner.harness };
|
|
85
|
+
const stopped = (exitCode, turns = 0) => ({
|
|
86
|
+
...base,
|
|
87
|
+
exitCode,
|
|
88
|
+
turns,
|
|
89
|
+
maxTurns: 0,
|
|
90
|
+
durationMs: 0
|
|
91
|
+
});
|
|
92
|
+
if (stage === "scaffold" || stage === "full") {
|
|
93
|
+
await runScaffoldStage(harnessRunner, progressLog);
|
|
94
|
+
}
|
|
95
|
+
if (stage === "scaffold")
|
|
96
|
+
return stopped(0);
|
|
97
|
+
// template, survey-assets, and survey-technology are independent (disjoint outputs, no git,
|
|
98
|
+
// surveys don't depend on the template). A "full" run does all three concurrently behind one
|
|
99
|
+
// barrier; a single-stage run does just the requested one.
|
|
100
|
+
if (stage === "full") {
|
|
101
|
+
const { exitCode } = options.skipTemplate
|
|
102
|
+
? await runSurveyGroup(harnessRunner, progressLog, prompt)
|
|
103
|
+
: await runTemplateGroup(harnessRunner, progressLog, prompt);
|
|
104
|
+
if (exitCode !== 0)
|
|
105
|
+
return stopped(exitCode);
|
|
106
|
+
}
|
|
107
|
+
else if (stage === "template") {
|
|
108
|
+
return stopped((await runTemplateStage(harnessRunner, progressLog, prompt)).exitCode);
|
|
109
|
+
}
|
|
110
|
+
else if (stage === "survey-assets") {
|
|
111
|
+
return stopped((await runSurveyAssetsStage(harnessRunner, progressLog, prompt)).exitCode);
|
|
112
|
+
}
|
|
113
|
+
else if (stage === "survey-technology") {
|
|
114
|
+
return stopped((await runSurveyTechnologyStage(harnessRunner, progressLog, prompt)).exitCode);
|
|
115
|
+
}
|
|
116
|
+
if (stage === "goal" || stage === "full") {
|
|
117
|
+
const { exitCode } = await runGoalStage(harnessRunner, progressLog, prompt);
|
|
118
|
+
if (exitCode !== 0)
|
|
119
|
+
return stopped(exitCode);
|
|
120
|
+
}
|
|
121
|
+
if (stage === "goal")
|
|
122
|
+
return stopped(0);
|
|
123
|
+
if (stage === "plan" || stage === "full") {
|
|
124
|
+
const { exitCode } = await runPlanStage(harnessRunner, progressLog, prompt);
|
|
125
|
+
if (exitCode !== 0)
|
|
126
|
+
return stopped(exitCode);
|
|
127
|
+
}
|
|
128
|
+
if (stage === "plan")
|
|
129
|
+
return stopped(0);
|
|
130
|
+
// "build" does a single build-turn; "full" works through the whole plan.
|
|
131
|
+
const turnBudget = stage === "build" ? 1 : options.maxTurns;
|
|
132
|
+
return {
|
|
133
|
+
...base,
|
|
134
|
+
...(await runBuildTurns(harnessRunner, prompt, { maxTurns: turnBudget, progressLog })),
|
|
135
|
+
maxTurns: 0,
|
|
136
|
+
durationMs: 0
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** Mechanical setup of the fresh project (skills, packages, dirs). No harness turn, no README. */
|
|
140
|
+
async function runScaffoldStage(harnessRunner, progressLog) {
|
|
141
|
+
const { cwd: projectDir, harness, runner } = harnessRunner;
|
|
142
|
+
progressLog.start("scaffolding");
|
|
143
|
+
try {
|
|
144
|
+
await ensureProjectGitignore(projectDir);
|
|
145
|
+
await ensureNpmrc(projectDir);
|
|
146
|
+
await ensureBaseProjectDirectories(projectDir);
|
|
147
|
+
await initNpmProject(projectDir, runner);
|
|
148
|
+
await installSkills(projectDir, harness, runner);
|
|
149
|
+
await installDependencies(projectDir, runner);
|
|
150
|
+
await commitAll(projectDir, runner, "chore: set up project scaffolding");
|
|
151
|
+
progressLog.succeed("scaffolding");
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
progressLog.fail("scaffolding");
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// opencode crashes ("Unexpected error") when several of its instances run in the same project
|
|
159
|
+
// dir at once — they contend over one per-project session/server. So the parallel template/survey
|
|
160
|
+
// turns run sequentially for it; every other harness runs them concurrently for speed.
|
|
161
|
+
const SERIAL_GROUP_HARNESSES = new Set(["opencode"]);
|
|
162
|
+
async function runGroupTurns(harnessRunner, prompts) {
|
|
163
|
+
if (SERIAL_GROUP_HARNESSES.has(harnessRunner.harness)) {
|
|
164
|
+
const results = [];
|
|
165
|
+
for (const prompt of prompts) {
|
|
166
|
+
results.push(await runTurn(harnessRunner, prompt));
|
|
167
|
+
}
|
|
168
|
+
return results;
|
|
169
|
+
}
|
|
170
|
+
return Promise.all(prompts.map((prompt) => runTurn(harnessRunner, prompt)));
|
|
171
|
+
}
|
|
172
|
+
/** Apply a fitting Market starter and survey assets/technology concurrently, then commit. */
|
|
173
|
+
async function runTemplateGroup(harnessRunner, progressLog, prompt) {
|
|
174
|
+
progressLog.start("template/surveys");
|
|
175
|
+
try {
|
|
176
|
+
// Array order is the call order: each runner pushes synchronously before its first await.
|
|
177
|
+
const results = await runGroupTurns(harnessRunner, [
|
|
178
|
+
buildTemplatePrompt(prompt),
|
|
179
|
+
buildSurveyAssetsPrompt(prompt),
|
|
180
|
+
buildSurveyTechnologyPrompt(prompt)
|
|
181
|
+
]);
|
|
182
|
+
const failed = results.find((result) => result.exitCode !== 0);
|
|
183
|
+
if (failed) {
|
|
184
|
+
progressLog.fail("template/surveys");
|
|
185
|
+
return failed;
|
|
186
|
+
}
|
|
187
|
+
await finishTemplate(harnessRunner);
|
|
188
|
+
progressLog.succeed("template/surveys");
|
|
189
|
+
return { exitCode: 0 };
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
progressLog.fail("template/surveys");
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Survey assets/technology in a full run without searching for or applying a template. */
|
|
197
|
+
async function runSurveyGroup(harnessRunner, progressLog, prompt) {
|
|
198
|
+
progressLog.start("surveys");
|
|
199
|
+
try {
|
|
200
|
+
const results = await runGroupTurns(harnessRunner, [
|
|
201
|
+
buildSurveyAssetsPrompt(prompt),
|
|
202
|
+
buildSurveyTechnologyPrompt(prompt)
|
|
203
|
+
]);
|
|
204
|
+
const failed = results.find((result) => result.exitCode !== 0);
|
|
205
|
+
if (failed) {
|
|
206
|
+
progressLog.fail("surveys");
|
|
207
|
+
return failed;
|
|
208
|
+
}
|
|
209
|
+
await finishScratchStart(harnessRunner);
|
|
210
|
+
progressLog.succeed("surveys");
|
|
211
|
+
return { exitCode: 0 };
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
progressLog.fail("surveys");
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** Apply a fitting Market starter into the current repo if one is found. */
|
|
219
|
+
async function runTemplateStage(harnessRunner, progressLog, prompt) {
|
|
220
|
+
progressLog.start("template");
|
|
221
|
+
try {
|
|
222
|
+
const template = await runTurn(harnessRunner, buildTemplatePrompt(prompt));
|
|
223
|
+
if (template.exitCode !== 0) {
|
|
224
|
+
progressLog.fail("template");
|
|
225
|
+
return template;
|
|
226
|
+
}
|
|
227
|
+
await finishTemplate(harnessRunner);
|
|
228
|
+
progressLog.succeed("template");
|
|
229
|
+
return { exitCode: 0 };
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
progressLog.fail("template");
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** Ensure a state-record README exists (templates may ship their own), then commit. */
|
|
237
|
+
async function finishTemplate(harnessRunner) {
|
|
238
|
+
await ensureStateReadme(harnessRunner.cwd);
|
|
239
|
+
await commitAll(harnessRunner.cwd, harnessRunner.runner, "chore: apply template");
|
|
240
|
+
}
|
|
241
|
+
/** Ensure the state record exists when a full run deliberately starts from scratch. */
|
|
242
|
+
async function finishScratchStart(harnessRunner) {
|
|
243
|
+
await ensureStateReadme(harnessRunner.cwd);
|
|
244
|
+
await commitAll(harnessRunner.cwd, harnessRunner.runner, "chore: start from scratch");
|
|
245
|
+
}
|
|
246
|
+
/** Think-turn: walk the fitting Market assets into a gitignored asset-survey. */
|
|
247
|
+
async function runSurveyAssetsStage(harnessRunner, progressLog, prompt) {
|
|
248
|
+
progressLog.start("survey assets");
|
|
249
|
+
try {
|
|
250
|
+
const result = await runTurn(harnessRunner, buildSurveyAssetsPrompt(prompt));
|
|
251
|
+
if (result.exitCode === 0)
|
|
252
|
+
progressLog.succeed("survey assets");
|
|
253
|
+
else
|
|
254
|
+
progressLog.fail("survey assets");
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
progressLog.fail("survey assets");
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** Think-turn: read the installed skills/packages into a gitignored technology-survey. */
|
|
263
|
+
async function runSurveyTechnologyStage(harnessRunner, progressLog, prompt) {
|
|
264
|
+
progressLog.start("survey technology");
|
|
265
|
+
try {
|
|
266
|
+
const result = await runTurn(harnessRunner, buildSurveyTechnologyPrompt(prompt));
|
|
267
|
+
if (result.exitCode === 0)
|
|
268
|
+
progressLog.succeed("survey technology");
|
|
269
|
+
else
|
|
270
|
+
progressLog.fail("survey technology");
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
progressLog.fail("survey technology");
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/** Think-turn: write the goal-record, grounded in the surveys and current state. */
|
|
279
|
+
async function runGoalStage(harnessRunner, progressLog, prompt) {
|
|
280
|
+
progressLog.start("goal");
|
|
281
|
+
try {
|
|
282
|
+
const goal = await runTurn(harnessRunner, buildGoalPrompt(prompt));
|
|
283
|
+
if (goal.exitCode !== 0) {
|
|
284
|
+
progressLog.fail("goal");
|
|
285
|
+
return goal;
|
|
286
|
+
}
|
|
287
|
+
if (!existsSync(join(harnessRunner.cwd, GOAL_FILE))) {
|
|
288
|
+
throw new CliError(`goal harness finished without creating ${GOAL_FILE}`);
|
|
289
|
+
}
|
|
290
|
+
await commitAll(harnessRunner.cwd, harnessRunner.runner, "docs: add goal");
|
|
291
|
+
progressLog.succeed("goal");
|
|
292
|
+
return { exitCode: 0 };
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
progressLog.fail("goal");
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/** Think-turn: write the plan-record from the goal, surveys, and current state. */
|
|
300
|
+
async function runPlanStage(harnessRunner, progressLog, prompt) {
|
|
301
|
+
progressLog.start("plan");
|
|
302
|
+
try {
|
|
303
|
+
const plan = await runTurn(harnessRunner, buildPlanPrompt(prompt));
|
|
304
|
+
if (plan.exitCode !== 0) {
|
|
305
|
+
progressLog.fail("plan");
|
|
306
|
+
return plan;
|
|
307
|
+
}
|
|
308
|
+
if (!existsSync(join(harnessRunner.cwd, PLAN_FILE))) {
|
|
309
|
+
throw new CliError(`plan harness finished without creating ${PLAN_FILE}`);
|
|
310
|
+
}
|
|
311
|
+
await commitAll(harnessRunner.cwd, harnessRunner.runner, "docs: add plan");
|
|
312
|
+
progressLog.succeed("plan");
|
|
313
|
+
return { exitCode: 0 };
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
progressLog.fail("plan");
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/** Build-stage: loop build-turns against the plan up to the build-turn-budget. */
|
|
321
|
+
export async function runBuildTurns(harnessRunner, userPrompt, options = {}) {
|
|
322
|
+
const planPath = join(harnessRunner.cwd, PLAN_FILE);
|
|
323
|
+
const maxTurns = options.maxTurns ?? MAX_BUILD_TURNS;
|
|
324
|
+
const progressLog = options.progressLog;
|
|
325
|
+
for (let turn = 1; turn <= maxTurns; turn += 1) {
|
|
326
|
+
if (!existsSync(planPath))
|
|
327
|
+
return { exitCode: 0, turns: turn - 1 };
|
|
328
|
+
const label = `build ${turn}/${maxTurns}`;
|
|
329
|
+
progressLog?.start(label);
|
|
330
|
+
const result = await runTurn(harnessRunner, buildBuildPrompt(userPrompt, turn));
|
|
331
|
+
if (result.exitCode !== 0 && !result.timedOut) {
|
|
332
|
+
progressLog?.fail(label);
|
|
333
|
+
return { exitCode: result.exitCode, turns: turn };
|
|
334
|
+
}
|
|
335
|
+
if (result.timedOut) {
|
|
336
|
+
progressLog?.fail(label);
|
|
337
|
+
console.error("build turn timed out; stopping this run");
|
|
338
|
+
return { exitCode: result.exitCode, turns: turn };
|
|
339
|
+
}
|
|
340
|
+
await commitAll(harnessRunner.cwd, harnessRunner.runner, `feat: build turn ${turn}`);
|
|
341
|
+
progressLog?.succeed(label);
|
|
342
|
+
}
|
|
343
|
+
if (!existsSync(planPath))
|
|
344
|
+
return { exitCode: 0, turns: maxTurns };
|
|
345
|
+
progressLog?.stop();
|
|
346
|
+
console.log(`Reached the ${maxTurns} build-turn budget; ${PLAN_FILE} still records remaining work`);
|
|
347
|
+
return { exitCode: 0, turns: maxTurns };
|
|
348
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type HarnessName } from "./constants.js";
|
|
2
|
+
import type { CommandExists, CommandInvocation, CommandResult, CommandRunner } from "./subprocess.js";
|
|
3
|
+
export type HarnessRunner = {
|
|
4
|
+
harness: HarnessName;
|
|
5
|
+
harnessArgs: string[];
|
|
6
|
+
timeoutMs: number;
|
|
7
|
+
cwd: string;
|
|
8
|
+
runner: CommandRunner;
|
|
9
|
+
};
|
|
10
|
+
export declare function selectHarness(requested: HarnessName | undefined, env: NodeJS.ProcessEnv, commandExists: CommandExists): Promise<HarnessName>;
|
|
11
|
+
export declare function harnessInvocation(harness: HarnessName, prompt: string, cwd: string, harnessArgs?: string[]): CommandInvocation;
|
|
12
|
+
/** Run one harness-turn: build the harness-command and hand it to the command-runner. */
|
|
13
|
+
export declare function runHarnessTurn(harnessRunner: HarnessRunner, prompt: string): Promise<CommandResult>;
|
package/dist/harness.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { CliError, HARNESS_NAMES } from "./constants.js";
|
|
2
|
+
export async function selectHarness(requested, env, commandExists) {
|
|
3
|
+
if (requested) {
|
|
4
|
+
if (await commandExists(requested, env))
|
|
5
|
+
return requested;
|
|
6
|
+
throw new CliError(`requested harness "${requested}" was not found on PATH`);
|
|
7
|
+
}
|
|
8
|
+
for (const harness of HARNESS_NAMES) {
|
|
9
|
+
if (await commandExists(harness, env))
|
|
10
|
+
return harness;
|
|
11
|
+
}
|
|
12
|
+
throw new CliError(`no supported harness found. Checked: ${HARNESS_NAMES.join(", ")}`);
|
|
13
|
+
}
|
|
14
|
+
export function harnessInvocation(harness, prompt, cwd, harnessArgs = []) {
|
|
15
|
+
switch (harness) {
|
|
16
|
+
case "opencode":
|
|
17
|
+
// "--dangerously-skip-permissions" auto-approves tool calls; without it opencode gates
|
|
18
|
+
// writes/commands in a headless run (no user to approve), so file writes get rejected and
|
|
19
|
+
// stages like goal finish without producing their output file. Matches claude/gemini/grok.
|
|
20
|
+
// "--dir <cwd>" pins opencode's project directory to the spawned cwd. opencode otherwise
|
|
21
|
+
// resolves its own project root by walking up to a git root, and the
|
|
22
|
+
// GIT_CEILING_DIRECTORIES=<parent> env that buildSubprocessEnv injects perturbs that walk
|
|
23
|
+
// so writes land in the PARENT of the project dir — it reports "Patch 1 file / Created X"
|
|
24
|
+
// but the file is written one level up, so create's existsSync(GOAL.md) check then fails.
|
|
25
|
+
return {
|
|
26
|
+
command: "opencode",
|
|
27
|
+
args: ["run", "--dangerously-skip-permissions", "--dir", cwd, ...harnessArgs, prompt],
|
|
28
|
+
cwd
|
|
29
|
+
};
|
|
30
|
+
case "codex":
|
|
31
|
+
return {
|
|
32
|
+
command: "codex",
|
|
33
|
+
args: ["exec", "--skip-git-repo-check", ...harnessArgs, prompt],
|
|
34
|
+
cwd
|
|
35
|
+
};
|
|
36
|
+
case "claude":
|
|
37
|
+
// The harness must run commands autonomously (Market searches, npm, vitexec proof checks,
|
|
38
|
+
// uikitml convert). "acceptEdits" only auto-approves file edits and gates every command,
|
|
39
|
+
// which deadlocks headless --print runs; bypassPermissions auto-approves commands too.
|
|
40
|
+
return {
|
|
41
|
+
command: "claude",
|
|
42
|
+
args: ["--print", "--permission-mode", "bypassPermissions", ...harnessArgs, prompt],
|
|
43
|
+
cwd
|
|
44
|
+
};
|
|
45
|
+
case "pi":
|
|
46
|
+
return { command: "pi", args: [...harnessArgs, "-p", prompt], cwd };
|
|
47
|
+
case "gemini":
|
|
48
|
+
// "yolo" auto-approves commands too; "auto_edit" would gate them like claude's acceptEdits.
|
|
49
|
+
// Hardcoded flags come before harnessArgs so users can override them, matching claude.
|
|
50
|
+
// "--skip-trust" is required for headless runs: in an untrusted folder Gemini silently
|
|
51
|
+
// downgrades "yolo" back to "default" (gating every tool) and then errors out, so the turn
|
|
52
|
+
// dies in ~1s without doing anything.
|
|
53
|
+
return {
|
|
54
|
+
command: "gemini",
|
|
55
|
+
args: ["--approval-mode", "yolo", "--skip-trust", ...harnessArgs, "--prompt", prompt],
|
|
56
|
+
cwd
|
|
57
|
+
};
|
|
58
|
+
case "grok":
|
|
59
|
+
// Headless single-turn: "-p" prints to stdout and exits; "--always-approve" auto-runs the
|
|
60
|
+
// commands/edits the build needs (npm, vitexec, file writes), matching claude/gemini's yolo.
|
|
61
|
+
return {
|
|
62
|
+
command: "grok",
|
|
63
|
+
args: ["--always-approve", "--output-format", "plain", ...harnessArgs, "-p", prompt],
|
|
64
|
+
cwd
|
|
65
|
+
};
|
|
66
|
+
case "forge":
|
|
67
|
+
// "-p <prompt>" runs one command non-interactively and exits; in this mode forge auto-runs
|
|
68
|
+
// its tools (no approve/yolo flag exists or is needed — verified it writes files unattended).
|
|
69
|
+
// "-C <cwd>" pins forge's working directory. forge otherwise blocks waiting on stdin, but
|
|
70
|
+
// create's runner spawns with stdin ignored, so the turn proceeds. The model and reasoning
|
|
71
|
+
// effort come from forge's own config (the configured Opus 4.8 high), not a CLI flag.
|
|
72
|
+
return { command: "forge", args: ["-C", cwd, ...harnessArgs, "-p", prompt], cwd };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Run one harness-turn: build the harness-command and hand it to the command-runner. */
|
|
76
|
+
export async function runHarnessTurn(harnessRunner, prompt) {
|
|
77
|
+
return harnessRunner.runner({
|
|
78
|
+
...harnessInvocation(harnessRunner.harness, prompt, harnessRunner.cwd, harnessRunner.harnessArgs),
|
|
79
|
+
timeoutMs: harnessRunner.timeoutMs
|
|
80
|
+
});
|
|
81
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { HarnessName } from "./constants.js";
|
|
2
|
+
export type ProgressLogOptions = {
|
|
3
|
+
stream?: NodeJS.WriteStream;
|
|
4
|
+
logFile?: string;
|
|
5
|
+
isTTY?: boolean;
|
|
6
|
+
now?: () => number;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Shows one live stage line on a TTY and permanent stage result lines everywhere.
|
|
10
|
+
* Notes are also mirrored into the session log so it reads as a transcript.
|
|
11
|
+
*/
|
|
12
|
+
export declare class ProgressLog {
|
|
13
|
+
private readonly stream;
|
|
14
|
+
private readonly isTTY;
|
|
15
|
+
private readonly logFile?;
|
|
16
|
+
private readonly now;
|
|
17
|
+
private timer?;
|
|
18
|
+
private frameIndex;
|
|
19
|
+
private activeStage?;
|
|
20
|
+
constructor(options?: ProgressLogOptions);
|
|
21
|
+
begin(projectName: string, harness: HarnessName, projectDir: string): void;
|
|
22
|
+
start(label: string): void;
|
|
23
|
+
succeed(label?: string | undefined): void;
|
|
24
|
+
fail(label?: string | undefined): void;
|
|
25
|
+
stop(): void;
|
|
26
|
+
private render;
|
|
27
|
+
private finish;
|
|
28
|
+
private clearActiveLine;
|
|
29
|
+
private appendToLog;
|
|
30
|
+
}
|
|
31
|
+
export declare function formatDuration(ms: number): string;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
3
|
+
const SPINNER_INTERVAL_MS = 80;
|
|
4
|
+
const CLEAR_LINE = "\r[K";
|
|
5
|
+
/**
|
|
6
|
+
* Shows one live stage line on a TTY and permanent stage result lines everywhere.
|
|
7
|
+
* Notes are also mirrored into the session log so it reads as a transcript.
|
|
8
|
+
*/
|
|
9
|
+
export class ProgressLog {
|
|
10
|
+
stream;
|
|
11
|
+
isTTY;
|
|
12
|
+
logFile;
|
|
13
|
+
now;
|
|
14
|
+
timer;
|
|
15
|
+
frameIndex = 0;
|
|
16
|
+
activeStage;
|
|
17
|
+
constructor(options = {}) {
|
|
18
|
+
this.stream = options.stream ?? process.stderr;
|
|
19
|
+
this.isTTY = options.isTTY ?? this.stream.isTTY === true;
|
|
20
|
+
this.logFile = options.logFile;
|
|
21
|
+
this.now = options.now ?? Date.now;
|
|
22
|
+
}
|
|
23
|
+
begin(projectName, harness, projectDir) {
|
|
24
|
+
this.stream.write(`Creating ${projectName} with ${harness}\n${projectDir}\n\n`);
|
|
25
|
+
this.appendToLog(`\n=== Creating ${projectName} with ${harness} ===\n${projectDir}\n`);
|
|
26
|
+
}
|
|
27
|
+
start(label) {
|
|
28
|
+
this.clearActiveLine();
|
|
29
|
+
this.activeStage = { label, startedAt: this.now() };
|
|
30
|
+
this.frameIndex = 0;
|
|
31
|
+
this.appendToLog(`\n=== ${label} started ===\n`);
|
|
32
|
+
if (!this.isTTY) {
|
|
33
|
+
this.stream.write(`${label} started\n`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (this.timer === undefined) {
|
|
37
|
+
this.timer = setInterval(() => this.render(), SPINNER_INTERVAL_MS);
|
|
38
|
+
this.timer.unref();
|
|
39
|
+
}
|
|
40
|
+
this.render();
|
|
41
|
+
}
|
|
42
|
+
succeed(label = this.activeStage?.label) {
|
|
43
|
+
this.finish("done", "✓", label);
|
|
44
|
+
}
|
|
45
|
+
fail(label = this.activeStage?.label) {
|
|
46
|
+
this.finish("failed", "✕", label);
|
|
47
|
+
}
|
|
48
|
+
stop() {
|
|
49
|
+
if (this.timer !== undefined) {
|
|
50
|
+
clearInterval(this.timer);
|
|
51
|
+
this.timer = undefined;
|
|
52
|
+
}
|
|
53
|
+
this.clearActiveLine();
|
|
54
|
+
}
|
|
55
|
+
render() {
|
|
56
|
+
if (this.activeStage === undefined)
|
|
57
|
+
return;
|
|
58
|
+
const frame = SPINNER_FRAMES[this.frameIndex % SPINNER_FRAMES.length];
|
|
59
|
+
this.frameIndex += 1;
|
|
60
|
+
const elapsed = formatDuration(this.now() - this.activeStage.startedAt);
|
|
61
|
+
this.stream.write(`\r${frame} ${this.activeStage.label} ${elapsed}[K`);
|
|
62
|
+
}
|
|
63
|
+
finish(state, icon, label) {
|
|
64
|
+
if (label === undefined)
|
|
65
|
+
return;
|
|
66
|
+
const startedAt = this.activeStage?.label === label ? this.activeStage.startedAt : this.now();
|
|
67
|
+
const elapsed = formatDuration(this.now() - startedAt);
|
|
68
|
+
this.stop();
|
|
69
|
+
this.stream.write(`${icon} ${label} ${state} in ${elapsed}\n`);
|
|
70
|
+
this.appendToLog(`\n=== ${label} ${state} in ${elapsed} ===\n`);
|
|
71
|
+
this.activeStage = undefined;
|
|
72
|
+
}
|
|
73
|
+
clearActiveLine() {
|
|
74
|
+
if (this.isTTY && this.activeStage !== undefined)
|
|
75
|
+
this.stream.write(CLEAR_LINE);
|
|
76
|
+
}
|
|
77
|
+
appendToLog(line) {
|
|
78
|
+
if (this.logFile === undefined)
|
|
79
|
+
return;
|
|
80
|
+
try {
|
|
81
|
+
appendFileSync(this.logFile, line);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Logging is best-effort and must never break a run.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export function formatDuration(ms) {
|
|
89
|
+
const totalSeconds = Math.max(0, Math.round(ms / 1000));
|
|
90
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
91
|
+
const seconds = totalSeconds % 60;
|
|
92
|
+
if (minutes === 0)
|
|
93
|
+
return `${seconds}s`;
|
|
94
|
+
return `${minutes}m ${seconds}s`;
|
|
95
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function buildTemplatePrompt(userPrompt: string): string;
|
|
2
|
+
export declare function buildSurveyAssetsPrompt(userPrompt: string): string;
|
|
3
|
+
export declare function buildSurveyTechnologyPrompt(userPrompt: string): string;
|
|
4
|
+
export declare function buildGoalPrompt(userPrompt: string): string;
|
|
5
|
+
export declare function buildPlanPrompt(userPrompt: string): string;
|
|
6
|
+
export declare function buildBuildPrompt(userPrompt: string, turn: number): string;
|