@drawcall/create 0.1.3 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/classify.d.ts +27 -0
- package/dist/classify.js +68 -0
- package/dist/cli.js +54 -11
- package/dist/command.d.ts +9 -11
- package/dist/command.js +101 -108
- package/dist/constants.d.ts +9 -4
- package/dist/constants.js +38 -15
- package/dist/errors.d.ts +39 -0
- package/dist/errors.js +32 -0
- package/dist/git.d.ts +27 -0
- package/dist/git.js +77 -0
- package/dist/harness.d.ts +22 -11
- package/dist/harness.js +37 -57
- package/dist/index.d.ts +7 -3
- package/dist/index.js +7 -3
- package/dist/logger.d.ts +11 -0
- package/dist/logger.js +31 -0
- package/dist/resume.d.ts +11 -0
- package/dist/resume.js +18 -0
- package/dist/scaffold.d.ts +11 -13
- package/dist/scaffold.js +108 -154
- package/dist/shell.d.ts +22 -0
- package/dist/shell.js +142 -0
- package/dist/stages.d.ts +18 -0
- package/dist/stages.js +120 -0
- package/dist/supervisor.d.ts +31 -27
- package/dist/supervisor.js +144 -100
- package/package.json +2 -1
- package/dist/create.d.ts +0 -35
- package/dist/create.js +0 -380
- package/dist/progress-log.d.ts +0 -31
- package/dist/progress-log.js +0 -95
- package/dist/subprocess.d.ts +0 -21
- package/dist/subprocess.js +0 -154
package/dist/create.js
DELETED
|
@@ -1,380 +0,0 @@
|
|
|
1
|
-
import { appendFileSync, 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
|
-
const waitMs = RETRY_BACKOFF_MS * (attempt - 1);
|
|
24
|
-
// A retry is otherwise invisible: the runner just logs a second identical "$ <harness> …"
|
|
25
|
-
// invocation. Mark it so the log reads as "turn failed → retrying" rather than as a mysterious
|
|
26
|
-
// duplicate run, and so a slow build turn can be told apart from one that silently re-ran.
|
|
27
|
-
logToSession(harnessRunner.cwd, `[drawcall-create] harness turn failed (exit ${result.exitCode}); retry ${attempt}/${HARNESS_TURN_ATTEMPTS} after ${Math.round(waitMs / 1000)}s`);
|
|
28
|
-
await delay(waitMs);
|
|
29
|
-
result = await runHarnessTurn(harnessRunner, prompt);
|
|
30
|
-
}
|
|
31
|
-
return result;
|
|
32
|
-
}
|
|
33
|
-
function logToSession(cwd, line) {
|
|
34
|
-
try {
|
|
35
|
-
appendFileSync(join(cwd, SESSION_LOG_FILE), `${line}\n`);
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
// No session log on disk (e.g. a test's caller-supplied runner) — the retry proceeds anyway.
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
export async function createProject(prompt, options = {}) {
|
|
42
|
-
const { env = process.env, commandExists = isCommandAvailable } = options;
|
|
43
|
-
const stage = options.stage ?? "full";
|
|
44
|
-
const cwd = resolve(options.cwd ?? process.cwd());
|
|
45
|
-
const harness = await selectHarness(options.harness, env, commandExists);
|
|
46
|
-
const maxTurns = options.maxTurns ?? MAX_BUILD_TURNS;
|
|
47
|
-
const startedAt = Date.now();
|
|
48
|
-
if (!(await commandExists("git", env))) {
|
|
49
|
-
throw new CliError("git is required but was not found on PATH");
|
|
50
|
-
}
|
|
51
|
-
// "scaffold" and "full" create a fresh project directory. The other stages
|
|
52
|
-
// continue an existing repo at the current working directory.
|
|
53
|
-
const createsProjectDir = stage === "scaffold" || stage === "full";
|
|
54
|
-
const projectName = createsProjectDir
|
|
55
|
-
? (options.projectName ?? generateProjectName())
|
|
56
|
-
: basename(cwd);
|
|
57
|
-
const projectDir = createsProjectDir ? resolve(cwd, projectName) : cwd;
|
|
58
|
-
if (createsProjectDir && existsSync(projectDir)) {
|
|
59
|
-
throw new CliError(`project directory already exists: ${projectDir}`);
|
|
60
|
-
}
|
|
61
|
-
if (!createsProjectDir && !existsSync(join(projectDir, ".git"))) {
|
|
62
|
-
throw new CliError(`the "${stage}" stage expects an existing git repo at ${projectDir}; run the scaffold stage first`);
|
|
63
|
-
}
|
|
64
|
-
// Capture every subprocess into the session-log so the terminal can stay a clean
|
|
65
|
-
// spinner. A caller-supplied runner opts out of both (it handles its own I/O).
|
|
66
|
-
const logFile = join(projectDir, SESSION_LOG_FILE);
|
|
67
|
-
const runner = options.runner ?? createSubprocessRunner({ env, logFile });
|
|
68
|
-
const progressLog = new ProgressLog({ logFile: options.runner ? undefined : logFile });
|
|
69
|
-
if (createsProjectDir) {
|
|
70
|
-
await mkdir(projectDir);
|
|
71
|
-
}
|
|
72
|
-
const harnessRunner = {
|
|
73
|
-
harness,
|
|
74
|
-
harnessArgs: options.harnessArgs ?? [],
|
|
75
|
-
timeoutMs: options.harnessTimeoutMs ?? DEFAULT_HARNESS_TIMEOUT_MS,
|
|
76
|
-
cwd: projectDir,
|
|
77
|
-
runner
|
|
78
|
-
};
|
|
79
|
-
const resultMaxTurns = stage === "full" ? maxTurns : stage === "build" ? 1 : 0;
|
|
80
|
-
progressLog.begin(projectName, harness, projectDir);
|
|
81
|
-
try {
|
|
82
|
-
if (createsProjectDir) {
|
|
83
|
-
await initGitRepo(projectDir, runner);
|
|
84
|
-
}
|
|
85
|
-
const result = await runStages(stage, harnessRunner, progressLog, prompt, projectName, {
|
|
86
|
-
maxTurns,
|
|
87
|
-
skipTemplate: options.skipTemplate === true
|
|
88
|
-
});
|
|
89
|
-
return { ...result, maxTurns: resultMaxTurns, durationMs: Date.now() - startedAt };
|
|
90
|
-
}
|
|
91
|
-
finally {
|
|
92
|
-
progressLog.stop();
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
/** Run the requested stage plus everything it implies, in pipeline order. */
|
|
96
|
-
async function runStages(stage, harnessRunner, progressLog, prompt, projectName, options) {
|
|
97
|
-
const base = { projectDir: harnessRunner.cwd, projectName, harness: harnessRunner.harness };
|
|
98
|
-
const stopped = (exitCode, turns = 0) => ({
|
|
99
|
-
...base,
|
|
100
|
-
exitCode,
|
|
101
|
-
turns,
|
|
102
|
-
maxTurns: 0,
|
|
103
|
-
durationMs: 0
|
|
104
|
-
});
|
|
105
|
-
if (stage === "scaffold" || stage === "full") {
|
|
106
|
-
await runScaffoldStage(harnessRunner, progressLog);
|
|
107
|
-
}
|
|
108
|
-
if (stage === "scaffold")
|
|
109
|
-
return stopped(0);
|
|
110
|
-
// A "full" run applies the template first, then surveys the applied state (assets + technology)
|
|
111
|
-
// concurrently behind one barrier — the surveys read the implementation the template installed,
|
|
112
|
-
// so they cannot run alongside it. A skip-template full run surveys a bare scaffold instead. A
|
|
113
|
-
// single-stage run does just the requested one (surveys then read whatever is already in the cwd).
|
|
114
|
-
if (stage === "full") {
|
|
115
|
-
const { exitCode } = options.skipTemplate
|
|
116
|
-
? await runSurveyGroup(harnessRunner, progressLog, prompt)
|
|
117
|
-
: await runTemplateGroup(harnessRunner, progressLog, prompt);
|
|
118
|
-
if (exitCode !== 0)
|
|
119
|
-
return stopped(exitCode);
|
|
120
|
-
}
|
|
121
|
-
else if (stage === "template") {
|
|
122
|
-
return stopped((await runTemplateStage(harnessRunner, progressLog, prompt)).exitCode);
|
|
123
|
-
}
|
|
124
|
-
else if (stage === "survey-assets") {
|
|
125
|
-
return stopped((await runSurveyAssetsStage(harnessRunner, progressLog, prompt)).exitCode);
|
|
126
|
-
}
|
|
127
|
-
else if (stage === "survey-technology") {
|
|
128
|
-
return stopped((await runSurveyTechnologyStage(harnessRunner, progressLog, prompt)).exitCode);
|
|
129
|
-
}
|
|
130
|
-
if (stage === "goal" || stage === "full") {
|
|
131
|
-
const { exitCode } = await runGoalStage(harnessRunner, progressLog, prompt);
|
|
132
|
-
if (exitCode !== 0)
|
|
133
|
-
return stopped(exitCode);
|
|
134
|
-
}
|
|
135
|
-
if (stage === "goal")
|
|
136
|
-
return stopped(0);
|
|
137
|
-
if (stage === "plan" || stage === "full") {
|
|
138
|
-
const { exitCode } = await runPlanStage(harnessRunner, progressLog, prompt);
|
|
139
|
-
if (exitCode !== 0)
|
|
140
|
-
return stopped(exitCode);
|
|
141
|
-
}
|
|
142
|
-
if (stage === "plan")
|
|
143
|
-
return stopped(0);
|
|
144
|
-
// "build" does a single build-turn; "full" works through the whole plan.
|
|
145
|
-
const turnBudget = stage === "build" ? 1 : options.maxTurns;
|
|
146
|
-
return {
|
|
147
|
-
...base,
|
|
148
|
-
...(await runBuildTurns(harnessRunner, prompt, { maxTurns: turnBudget, progressLog })),
|
|
149
|
-
maxTurns: 0,
|
|
150
|
-
durationMs: 0
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
/** Mechanical setup of the fresh project (skills, packages, dirs). No harness turn, no README. */
|
|
154
|
-
async function runScaffoldStage(harnessRunner, progressLog) {
|
|
155
|
-
const { cwd: projectDir, harness, runner } = harnessRunner;
|
|
156
|
-
progressLog.start("scaffolding");
|
|
157
|
-
try {
|
|
158
|
-
await ensureProjectGitignore(projectDir);
|
|
159
|
-
await ensureNpmrc(projectDir);
|
|
160
|
-
await ensureBaseProjectDirectories(projectDir);
|
|
161
|
-
await initNpmProject(projectDir, runner);
|
|
162
|
-
// Dependencies first: the private-repo skills are installed from their package directory under
|
|
163
|
-
// node_modules, which installDependencies creates (see installSkills / PACKAGE_SKILLS).
|
|
164
|
-
await installDependencies(projectDir, runner);
|
|
165
|
-
await installSkills(projectDir, harness, runner);
|
|
166
|
-
await commitAll(projectDir, runner, "chore: set up project scaffolding");
|
|
167
|
-
progressLog.succeed("scaffolding");
|
|
168
|
-
}
|
|
169
|
-
catch (error) {
|
|
170
|
-
progressLog.fail("scaffolding");
|
|
171
|
-
throw error;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
// opencode crashes ("Unexpected error") when several of its instances run in the same project
|
|
175
|
-
// dir at once — they contend over one per-project session/server. So the parallel template/survey
|
|
176
|
-
// turns run sequentially for it; every other harness runs them concurrently for speed.
|
|
177
|
-
const SERIAL_GROUP_HARNESSES = new Set(["opencode"]);
|
|
178
|
-
async function runGroupTurns(harnessRunner, prompts) {
|
|
179
|
-
if (SERIAL_GROUP_HARNESSES.has(harnessRunner.harness)) {
|
|
180
|
-
const results = [];
|
|
181
|
-
for (const prompt of prompts) {
|
|
182
|
-
results.push(await runTurn(harnessRunner, prompt));
|
|
183
|
-
}
|
|
184
|
-
return results;
|
|
185
|
-
}
|
|
186
|
-
return Promise.all(prompts.map((prompt) => runTurn(harnessRunner, prompt)));
|
|
187
|
-
}
|
|
188
|
-
/** Apply a fitting Market starter, then survey assets/technology over the applied state. */
|
|
189
|
-
async function runTemplateGroup(harnessRunner, progressLog, prompt) {
|
|
190
|
-
// The template runs first and commits, so the surveys that follow can read the applied
|
|
191
|
-
// implementation (its src/ and the assets it installed via Market), not race them — the
|
|
192
|
-
// whole point of the template path is to survey what is already here and modify it.
|
|
193
|
-
progressLog.start("template");
|
|
194
|
-
try {
|
|
195
|
-
const template = await runTurn(harnessRunner, buildTemplatePrompt(prompt));
|
|
196
|
-
if (template.exitCode !== 0) {
|
|
197
|
-
progressLog.fail("template");
|
|
198
|
-
return template;
|
|
199
|
-
}
|
|
200
|
-
await finishTemplate(harnessRunner);
|
|
201
|
-
progressLog.succeed("template");
|
|
202
|
-
}
|
|
203
|
-
catch (error) {
|
|
204
|
-
progressLog.fail("template");
|
|
205
|
-
throw error;
|
|
206
|
-
}
|
|
207
|
-
// The two surveys write disjoint scratch files and don't depend on each other, so they run
|
|
208
|
-
// concurrently (serially for opencode — see SERIAL_GROUP_HARNESSES).
|
|
209
|
-
progressLog.start("surveys");
|
|
210
|
-
try {
|
|
211
|
-
const results = await runGroupTurns(harnessRunner, [
|
|
212
|
-
buildSurveyAssetsPrompt(prompt),
|
|
213
|
-
buildSurveyTechnologyPrompt(prompt)
|
|
214
|
-
]);
|
|
215
|
-
const failed = results.find((result) => result.exitCode !== 0);
|
|
216
|
-
if (failed) {
|
|
217
|
-
progressLog.fail("surveys");
|
|
218
|
-
return failed;
|
|
219
|
-
}
|
|
220
|
-
progressLog.succeed("surveys");
|
|
221
|
-
return { exitCode: 0 };
|
|
222
|
-
}
|
|
223
|
-
catch (error) {
|
|
224
|
-
progressLog.fail("surveys");
|
|
225
|
-
throw error;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
/** Survey assets/technology in a full run without searching for or applying a template. */
|
|
229
|
-
async function runSurveyGroup(harnessRunner, progressLog, prompt) {
|
|
230
|
-
progressLog.start("surveys");
|
|
231
|
-
try {
|
|
232
|
-
const results = await runGroupTurns(harnessRunner, [
|
|
233
|
-
buildSurveyAssetsPrompt(prompt),
|
|
234
|
-
buildSurveyTechnologyPrompt(prompt)
|
|
235
|
-
]);
|
|
236
|
-
const failed = results.find((result) => result.exitCode !== 0);
|
|
237
|
-
if (failed) {
|
|
238
|
-
progressLog.fail("surveys");
|
|
239
|
-
return failed;
|
|
240
|
-
}
|
|
241
|
-
await finishScratchStart(harnessRunner);
|
|
242
|
-
progressLog.succeed("surveys");
|
|
243
|
-
return { exitCode: 0 };
|
|
244
|
-
}
|
|
245
|
-
catch (error) {
|
|
246
|
-
progressLog.fail("surveys");
|
|
247
|
-
throw error;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
/** Apply a fitting Market starter into the current repo if one is found. */
|
|
251
|
-
async function runTemplateStage(harnessRunner, progressLog, prompt) {
|
|
252
|
-
progressLog.start("template");
|
|
253
|
-
try {
|
|
254
|
-
const template = await runTurn(harnessRunner, buildTemplatePrompt(prompt));
|
|
255
|
-
if (template.exitCode !== 0) {
|
|
256
|
-
progressLog.fail("template");
|
|
257
|
-
return template;
|
|
258
|
-
}
|
|
259
|
-
await finishTemplate(harnessRunner);
|
|
260
|
-
progressLog.succeed("template");
|
|
261
|
-
return { exitCode: 0 };
|
|
262
|
-
}
|
|
263
|
-
catch (error) {
|
|
264
|
-
progressLog.fail("template");
|
|
265
|
-
throw error;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
/** Ensure a state-record README exists (templates may ship their own), then commit. */
|
|
269
|
-
async function finishTemplate(harnessRunner) {
|
|
270
|
-
await ensureStateReadme(harnessRunner.cwd);
|
|
271
|
-
await commitAll(harnessRunner.cwd, harnessRunner.runner, "chore: apply template");
|
|
272
|
-
}
|
|
273
|
-
/** Ensure the state record exists when a full run deliberately starts from scratch. */
|
|
274
|
-
async function finishScratchStart(harnessRunner) {
|
|
275
|
-
await ensureStateReadme(harnessRunner.cwd);
|
|
276
|
-
await commitAll(harnessRunner.cwd, harnessRunner.runner, "chore: start from scratch");
|
|
277
|
-
}
|
|
278
|
-
/** Think-turn: walk the fitting Market assets into a gitignored asset-survey. */
|
|
279
|
-
async function runSurveyAssetsStage(harnessRunner, progressLog, prompt) {
|
|
280
|
-
progressLog.start("survey assets");
|
|
281
|
-
try {
|
|
282
|
-
const result = await runTurn(harnessRunner, buildSurveyAssetsPrompt(prompt));
|
|
283
|
-
if (result.exitCode === 0)
|
|
284
|
-
progressLog.succeed("survey assets");
|
|
285
|
-
else
|
|
286
|
-
progressLog.fail("survey assets");
|
|
287
|
-
return result;
|
|
288
|
-
}
|
|
289
|
-
catch (error) {
|
|
290
|
-
progressLog.fail("survey assets");
|
|
291
|
-
throw error;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
/** Think-turn: read the installed skills/packages into a gitignored technology-survey. */
|
|
295
|
-
async function runSurveyTechnologyStage(harnessRunner, progressLog, prompt) {
|
|
296
|
-
progressLog.start("survey technology");
|
|
297
|
-
try {
|
|
298
|
-
const result = await runTurn(harnessRunner, buildSurveyTechnologyPrompt(prompt));
|
|
299
|
-
if (result.exitCode === 0)
|
|
300
|
-
progressLog.succeed("survey technology");
|
|
301
|
-
else
|
|
302
|
-
progressLog.fail("survey technology");
|
|
303
|
-
return result;
|
|
304
|
-
}
|
|
305
|
-
catch (error) {
|
|
306
|
-
progressLog.fail("survey technology");
|
|
307
|
-
throw error;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
/** Think-turn: write the goal-record, grounded in the surveys and current state. */
|
|
311
|
-
async function runGoalStage(harnessRunner, progressLog, prompt) {
|
|
312
|
-
progressLog.start("goal");
|
|
313
|
-
try {
|
|
314
|
-
const goal = await runTurn(harnessRunner, buildGoalPrompt(prompt));
|
|
315
|
-
if (goal.exitCode !== 0) {
|
|
316
|
-
progressLog.fail("goal");
|
|
317
|
-
return goal;
|
|
318
|
-
}
|
|
319
|
-
if (!existsSync(join(harnessRunner.cwd, GOAL_FILE))) {
|
|
320
|
-
throw new CliError(`goal harness finished without creating ${GOAL_FILE}`);
|
|
321
|
-
}
|
|
322
|
-
await commitAll(harnessRunner.cwd, harnessRunner.runner, "docs: add goal");
|
|
323
|
-
progressLog.succeed("goal");
|
|
324
|
-
return { exitCode: 0 };
|
|
325
|
-
}
|
|
326
|
-
catch (error) {
|
|
327
|
-
progressLog.fail("goal");
|
|
328
|
-
throw error;
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
/** Think-turn: write the plan-record from the goal, surveys, and current state. */
|
|
332
|
-
async function runPlanStage(harnessRunner, progressLog, prompt) {
|
|
333
|
-
progressLog.start("plan");
|
|
334
|
-
try {
|
|
335
|
-
const plan = await runTurn(harnessRunner, buildPlanPrompt(prompt));
|
|
336
|
-
if (plan.exitCode !== 0) {
|
|
337
|
-
progressLog.fail("plan");
|
|
338
|
-
return plan;
|
|
339
|
-
}
|
|
340
|
-
if (!existsSync(join(harnessRunner.cwd, PLAN_FILE))) {
|
|
341
|
-
throw new CliError(`plan harness finished without creating ${PLAN_FILE}`);
|
|
342
|
-
}
|
|
343
|
-
await commitAll(harnessRunner.cwd, harnessRunner.runner, "docs: add plan");
|
|
344
|
-
progressLog.succeed("plan");
|
|
345
|
-
return { exitCode: 0 };
|
|
346
|
-
}
|
|
347
|
-
catch (error) {
|
|
348
|
-
progressLog.fail("plan");
|
|
349
|
-
throw error;
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
/** Build-stage: loop build-turns against the plan up to the build-turn-budget. */
|
|
353
|
-
export async function runBuildTurns(harnessRunner, userPrompt, options = {}) {
|
|
354
|
-
const planPath = join(harnessRunner.cwd, PLAN_FILE);
|
|
355
|
-
const maxTurns = options.maxTurns ?? MAX_BUILD_TURNS;
|
|
356
|
-
const progressLog = options.progressLog;
|
|
357
|
-
for (let turn = 1; turn <= maxTurns; turn += 1) {
|
|
358
|
-
if (!existsSync(planPath))
|
|
359
|
-
return { exitCode: 0, turns: turn - 1 };
|
|
360
|
-
const label = `build ${turn}/${maxTurns}`;
|
|
361
|
-
progressLog?.start(label);
|
|
362
|
-
const result = await runTurn(harnessRunner, buildBuildPrompt(userPrompt, turn));
|
|
363
|
-
if (result.exitCode !== 0 && !result.timedOut) {
|
|
364
|
-
progressLog?.fail(label);
|
|
365
|
-
return { exitCode: result.exitCode, turns: turn };
|
|
366
|
-
}
|
|
367
|
-
if (result.timedOut) {
|
|
368
|
-
progressLog?.fail(label);
|
|
369
|
-
console.error("build turn timed out; stopping this run");
|
|
370
|
-
return { exitCode: result.exitCode, turns: turn };
|
|
371
|
-
}
|
|
372
|
-
await commitAll(harnessRunner.cwd, harnessRunner.runner, `feat: build turn ${turn}`);
|
|
373
|
-
progressLog?.succeed(label);
|
|
374
|
-
}
|
|
375
|
-
if (!existsSync(planPath))
|
|
376
|
-
return { exitCode: 0, turns: maxTurns };
|
|
377
|
-
progressLog?.stop();
|
|
378
|
-
console.log(`Reached the ${maxTurns} build-turn budget; ${PLAN_FILE} still records remaining work`);
|
|
379
|
-
return { exitCode: 0, turns: maxTurns };
|
|
380
|
-
}
|
package/dist/progress-log.d.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
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;
|
package/dist/progress-log.js
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
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
|
-
}
|
package/dist/subprocess.d.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { type SpawnOptions } from "node:child_process";
|
|
2
|
-
export type CommandInvocation = {
|
|
3
|
-
command: string;
|
|
4
|
-
args: string[];
|
|
5
|
-
cwd: string;
|
|
6
|
-
timeoutMs?: number;
|
|
7
|
-
};
|
|
8
|
-
export type CommandResult = {
|
|
9
|
-
exitCode: number;
|
|
10
|
-
timedOut?: boolean;
|
|
11
|
-
};
|
|
12
|
-
export type CommandRunner = (invocation: CommandInvocation) => Promise<CommandResult>;
|
|
13
|
-
export type CommandExists = (command: string, env: NodeJS.ProcessEnv) => Promise<boolean>;
|
|
14
|
-
export type SubprocessRunnerOptions = Pick<SpawnOptions, "stdio" | "env"> & {
|
|
15
|
-
killGraceMs?: number;
|
|
16
|
-
logFile?: string;
|
|
17
|
-
};
|
|
18
|
-
export declare function createSubprocessRunner(options?: SubprocessRunnerOptions): CommandRunner;
|
|
19
|
-
export declare function buildSubprocessEnv(cwd: string, baseEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
20
|
-
export declare function isCommandAvailable(command: string, env?: NodeJS.ProcessEnv): Promise<boolean>;
|
|
21
|
-
export declare function assertExitCode(run: Promise<CommandResult>, message: string): Promise<void>;
|
package/dist/subprocess.js
DELETED
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { createWriteStream } from "node:fs";
|
|
3
|
-
import { delimiter, dirname, join, resolve } from "node:path";
|
|
4
|
-
import { createInterface } from "node:readline";
|
|
5
|
-
import which from "which";
|
|
6
|
-
import { CliError, TIMEOUT_EXIT_CODE, TIMEOUT_KILL_GRACE_MS } from "./constants.js";
|
|
7
|
-
import { formatDuration } from "./progress-log.js";
|
|
8
|
-
export function createSubprocessRunner(options = { stdio: "inherit" }) {
|
|
9
|
-
return (invocation) => runSubprocess(invocation, options);
|
|
10
|
-
}
|
|
11
|
-
function runSubprocess({ command, args, cwd, timeoutMs }, options) {
|
|
12
|
-
return new Promise((resolveResult, reject) => {
|
|
13
|
-
const startedAt = Date.now();
|
|
14
|
-
// A separate process group lets a timeout kill the whole child tree at once.
|
|
15
|
-
const detached = process.platform !== "win32";
|
|
16
|
-
// When a log file is set, capture all child output into it and keep the
|
|
17
|
-
// terminal clean for the progress note; otherwise stream straight through.
|
|
18
|
-
const logStream = openSessionLog(options.logFile, [command, ...args]);
|
|
19
|
-
const child = spawn(command, args, {
|
|
20
|
-
cwd,
|
|
21
|
-
detached,
|
|
22
|
-
env: buildSubprocessEnv(cwd, options.env ?? process.env),
|
|
23
|
-
stdio: logStream ? ["ignore", "pipe", "pipe"] : (options.stdio ?? "inherit")
|
|
24
|
-
});
|
|
25
|
-
if (logStream) {
|
|
26
|
-
// Stamp every captured line with the wall-clock time we received it instead of piping raw
|
|
27
|
-
// bytes. Reading the harness's output at our own boundary and timing it with our own clock is
|
|
28
|
-
// the one timing signal robust across every harness and version — it depends on no private
|
|
29
|
-
// session-transcript format or structured-output schema (both drift, and for backgrounded
|
|
30
|
-
// tool calls misreport durations). The gaps between timestamps are then where the wall-clock
|
|
31
|
-
// went: a long stall with no output is the model thinking or one command grinding.
|
|
32
|
-
forwardWithTimestamps(child.stdout, logStream);
|
|
33
|
-
forwardWithTimestamps(child.stderr, logStream);
|
|
34
|
-
}
|
|
35
|
-
let timedOut = false;
|
|
36
|
-
let killTimer;
|
|
37
|
-
let timeout;
|
|
38
|
-
if (timeoutMs !== undefined) {
|
|
39
|
-
timeout = setTimeout(() => {
|
|
40
|
-
timedOut = true;
|
|
41
|
-
const notice = `[drawcall-create] ${command} timed out after ${Math.ceil(timeoutMs / 1000)}s`;
|
|
42
|
-
if (logStream)
|
|
43
|
-
logStream.write(`${notice}\n`);
|
|
44
|
-
else
|
|
45
|
-
console.error(notice);
|
|
46
|
-
killChildProcess(child.pid, detached, "SIGTERM");
|
|
47
|
-
const grace = options.killGraceMs ?? TIMEOUT_KILL_GRACE_MS;
|
|
48
|
-
killTimer = setTimeout(() => killChildProcess(child.pid, detached, "SIGKILL"), grace);
|
|
49
|
-
}, timeoutMs);
|
|
50
|
-
}
|
|
51
|
-
// The child sits in its own process group, so terminal signals never reach it directly.
|
|
52
|
-
const forwardSignal = (signal) => {
|
|
53
|
-
killChildProcess(child.pid, detached, signal);
|
|
54
|
-
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
55
|
-
};
|
|
56
|
-
process.on("SIGINT", forwardSignal);
|
|
57
|
-
process.on("SIGTERM", forwardSignal);
|
|
58
|
-
const cleanup = () => {
|
|
59
|
-
if (timeout)
|
|
60
|
-
clearTimeout(timeout);
|
|
61
|
-
if (killTimer)
|
|
62
|
-
clearTimeout(killTimer);
|
|
63
|
-
process.off("SIGINT", forwardSignal);
|
|
64
|
-
process.off("SIGTERM", forwardSignal);
|
|
65
|
-
logStream?.end();
|
|
66
|
-
};
|
|
67
|
-
child.once("error", (error) => {
|
|
68
|
-
cleanup();
|
|
69
|
-
reject(error);
|
|
70
|
-
});
|
|
71
|
-
child.once("exit", (code, signal) => {
|
|
72
|
-
const exitCode = timedOut ? TIMEOUT_EXIT_CODE : signal ? 1 : (code ?? 1);
|
|
73
|
-
// Always record how long the command ran, so the log shows where wall-clock goes (each
|
|
74
|
-
// scaffold step, and each harness turn's total) without digging into the harness's own
|
|
75
|
-
// session transcript. A non-zero exit is named on the same line so a failure is unmistakable
|
|
76
|
-
// and the boundary between a failed turn and a following retry stays legible. Timeouts
|
|
77
|
-
// already logged their own notice, so only the duration is added for them.
|
|
78
|
-
if (logStream) {
|
|
79
|
-
const elapsed = formatDuration(Date.now() - startedAt);
|
|
80
|
-
const failure = !timedOut && exitCode !== 0
|
|
81
|
-
? ` — exit ${exitCode}${signal ? ` (signal ${signal})` : ""}`
|
|
82
|
-
: "";
|
|
83
|
-
logStream.write(`[drawcall-create] ${command} done in ${elapsed}${failure}\n`);
|
|
84
|
-
}
|
|
85
|
-
cleanup();
|
|
86
|
-
resolveResult({ exitCode, timedOut });
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
export function buildSubprocessEnv(cwd, baseEnv = process.env) {
|
|
91
|
-
const projectParent = dirname(resolve(cwd));
|
|
92
|
-
const localBin = join(resolve(cwd), "node_modules", ".bin");
|
|
93
|
-
const existingCeiling = baseEnv.GIT_CEILING_DIRECTORIES;
|
|
94
|
-
const existingPath = baseEnv.PATH;
|
|
95
|
-
return {
|
|
96
|
-
...baseEnv,
|
|
97
|
-
PATH: existingPath ? `${localBin}${delimiter}${existingPath}` : localBin,
|
|
98
|
-
GIT_CEILING_DIRECTORIES: existingCeiling
|
|
99
|
-
? `${projectParent}${delimiter}${existingCeiling}`
|
|
100
|
-
: projectParent
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
export async function isCommandAvailable(command, env = process.env) {
|
|
104
|
-
const resolved = await which(command, {
|
|
105
|
-
nothrow: true,
|
|
106
|
-
path: env.PATH,
|
|
107
|
-
pathExt: env.PATHEXT
|
|
108
|
-
});
|
|
109
|
-
return resolved !== null;
|
|
110
|
-
}
|
|
111
|
-
export async function assertExitCode(run, message) {
|
|
112
|
-
const { exitCode } = await run;
|
|
113
|
-
if (exitCode !== 0)
|
|
114
|
-
throw new CliError(`${message} (exit code ${exitCode})`);
|
|
115
|
-
}
|
|
116
|
-
function openSessionLog(logFile, command) {
|
|
117
|
-
if (logFile === undefined)
|
|
118
|
-
return undefined;
|
|
119
|
-
const stream = createWriteStream(logFile, { flags: "a" });
|
|
120
|
-
stream.write(`\n$ ${command.join(" ")}\n`);
|
|
121
|
-
return stream;
|
|
122
|
-
}
|
|
123
|
-
// Copy a child stream into the log a line at a time, stamping each line with the wall-clock time we
|
|
124
|
-
// received it. readline does the line splitting (including a final line with no trailing newline,
|
|
125
|
-
// and \r\n) so a timestamp only ever prefixes a whole line.
|
|
126
|
-
function forwardWithTimestamps(source, sink) {
|
|
127
|
-
if (!source)
|
|
128
|
-
return;
|
|
129
|
-
const lines = createInterface({ input: source });
|
|
130
|
-
lines.on("line", (line) => sink.write(`${logTimestamp()} ${line}\n`));
|
|
131
|
-
}
|
|
132
|
-
// Absolute wall-clock HH:MM:SS.mmm so any two lines anywhere in the log — within a turn or across
|
|
133
|
-
// stages — can be subtracted directly, and the gap from the last output line to the "done in"
|
|
134
|
-
// footer (the idle tail before the process exits) is visible too.
|
|
135
|
-
function logTimestamp() {
|
|
136
|
-
const now = new Date();
|
|
137
|
-
const pad = (value, length = 2) => String(value).padStart(length, "0");
|
|
138
|
-
return `[${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${pad(now.getMilliseconds(), 3)}]`;
|
|
139
|
-
}
|
|
140
|
-
function killChildProcess(pid, detached, signal) {
|
|
141
|
-
if (!pid)
|
|
142
|
-
return;
|
|
143
|
-
if (process.platform === "win32") {
|
|
144
|
-
// Signals only reach the direct child (often a cmd shim); taskkill tears down the tree.
|
|
145
|
-
spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" }).unref();
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
try {
|
|
149
|
-
process.kill(detached ? -pid : pid, signal);
|
|
150
|
-
}
|
|
151
|
-
catch {
|
|
152
|
-
// The process may have exited between the timeout and kill attempt.
|
|
153
|
-
}
|
|
154
|
-
}
|