@drawcall/create 0.1.3 → 0.2.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.
@@ -1,28 +1,32 @@
1
+ import { Duration, Effect } from "effect";
2
+ import { type FailureReason } from "./classify.js";
1
3
  import { type HarnessName } from "./constants.js";
2
- import { type CommandResult } from "./subprocess.js";
3
- export type RunBuildTurnChild = (cwd: string) => Promise<CommandResult>;
4
- export type SuperviseBuildOptions = {
5
- cwd?: string;
6
- env?: NodeJS.ProcessEnv;
7
- maxTurns?: number;
8
- harness?: HarnessName;
9
- harnessArgs?: string[];
10
- harnessTimeoutMinutes?: number;
11
- runBuildTurnChild?: RunBuildTurnChild;
12
- };
13
- export type SuperviseBuildResult = {
14
- projectDir: string;
15
- turns: number;
16
- /** Why the loop stopped — surfaced to the caller and the session log. */
17
- stop: "plan-consumed" | "budget-exhausted" | "stuck";
18
- };
19
- /**
20
- * Run the build stage so it ALWAYS completes or cleanly resumes, even when a turn's child process
21
- * is killed mid-turn by an uncatchable OOM/jetsam SIGKILL. Each turn runs as a separate child, so
22
- * this supervisor holds almost no memory and is an unlikely OOM victim itself; if a turn-child dies
23
- * without committing, the supervisor resets to the last known-good commit and retries.
24
- *
25
- * The repo must already be scaffolded, surveyed, and planned (a committed PLAN.md) — run the
26
- * earlier stages with a normal `createProject` first. This is the build loop only.
27
- */
28
- export declare function superviseBuild(options?: SuperviseBuildOptions): Promise<SuperviseBuildResult>;
4
+ import { PreflightError } from "./errors.js";
5
+ import { Git } from "./git.js";
6
+ import { Harness } from "./harness.js";
7
+ import { Logger } from "./logger.js";
8
+ import { Scaffold } from "./scaffold.js";
9
+ export interface RunOptions {
10
+ readonly prompt?: string;
11
+ readonly cwd?: string;
12
+ readonly resume?: boolean;
13
+ readonly projectName?: string;
14
+ readonly here?: boolean;
15
+ readonly harness?: HarnessName;
16
+ readonly harnessArgs?: ReadonlyArray<string>;
17
+ readonly harnessTimeoutMs?: number;
18
+ readonly maxTurns?: number;
19
+ readonly skipTemplate?: boolean;
20
+ readonly backoff?: (attempt: number) => Duration.Duration;
21
+ }
22
+ export type Outcome = "done" | "stuck" | "budget-exhausted";
23
+ export interface RunResult {
24
+ readonly projectDir: string;
25
+ readonly outcome: Outcome;
26
+ readonly staleAttempts: number;
27
+ readonly errors: ReadonlyArray<FailureReason>;
28
+ readonly buildTurns: number;
29
+ }
30
+ type Env = Harness | Scaffold | Git | Logger;
31
+ export declare const run: (options: RunOptions) => Effect.Effect<RunResult, PreflightError, Env>;
32
+ export {};
@@ -1,110 +1,154 @@
1
- import { execFileSync } from "node:child_process";
2
- import { appendFileSync, existsSync, readFileSync } from "node:fs";
1
+ import { existsSync } from "node:fs";
3
2
  import { join, resolve } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { CliError, MAX_BUILD_TURNS, PLAN_FILE, SESSION_LOG_FILE } from "./constants.js";
6
- import { createSubprocessRunner } from "./subprocess.js";
7
- // Two consecutive turns that neither advance HEAD with real work nor touch PLAN.md mean the build
8
- // is wedged (a turn that keeps crashing the same way, or a no-op turn the harness can't get past).
9
- // Stopping then is honest: looping further just burns hours re-running the identical dead step.
10
- const STUCK_ATTEMPT_LIMIT = 2;
11
- /**
12
- * Run the build stage so it ALWAYS completes or cleanly resumes, even when a turn's child process
13
- * is killed mid-turn by an uncatchable OOM/jetsam SIGKILL. Each turn runs as a separate child, so
14
- * this supervisor holds almost no memory and is an unlikely OOM victim itself; if a turn-child dies
15
- * without committing, the supervisor resets to the last known-good commit and retries.
16
- *
17
- * The repo must already be scaffolded, surveyed, and planned (a committed PLAN.md) — run the
18
- * earlier stages with a normal `createProject` first. This is the build loop only.
19
- */
20
- export async function superviseBuild(options = {}) {
21
- const cwd = resolve(options.cwd ?? process.cwd());
22
- const env = options.env ?? process.env;
23
- const maxTurns = options.maxTurns ?? MAX_BUILD_TURNS;
24
- const runBuildTurnChild = options.runBuildTurnChild ?? defaultRunBuildTurnChild(env, options);
25
- if (!existsSync(join(cwd, ".git"))) {
26
- throw new CliError(`supervised build expects an existing git repo at ${cwd}`);
27
- }
28
- const planPath = join(cwd, PLAN_FILE);
29
- let staleAttempts = 0;
30
- for (let turn = 1; turn <= maxTurns; turn += 1) {
31
- if (!existsSync(planPath))
32
- return done(cwd, turn - 1, "plan-consumed");
33
- // The soccer hazard: a prior turn killed mid-edit can leave the tree dirty with partial,
34
- // partially-destructive changes (e.g. half-deleted source). Build turns re-read repo state and
35
- // redo the step, so resume ONLY from a clean committed state. `clean -fd` (not -x) preserves
36
- // node_modules and the gitignored surveys/proof scratch; never `git add -A` here.
37
- resetToLastGoodCommit(cwd);
38
- const before = captureProgressMarker(cwd, planPath);
39
- await runBuildTurnChild(cwd);
40
- const after = captureProgressMarker(cwd, planPath);
41
- if (!existsSync(planPath))
42
- return done(cwd, turn, "plan-consumed");
43
- if (madeProgress(before, after)) {
44
- staleAttempts = 0;
45
- continue;
46
- }
47
- staleAttempts += 1;
48
- if (staleAttempts >= STUCK_ATTEMPT_LIMIT) {
49
- logToSession(cwd, `[drawcall-create] supervised build stuck: ${STUCK_ATTEMPT_LIMIT} turns with no new commit and no ${PLAN_FILE} change`);
50
- return done(cwd, turn, "stuck");
51
- }
52
- }
53
- return done(cwd, maxTurns, existsSync(planPath) ? "budget-exhausted" : "plan-consumed");
54
- }
55
- function madeProgress(before, after) {
56
- return after.head !== before.head || after.planText !== before.planText;
3
+ import { Console, Duration, Effect } from "effect";
4
+ import { humanReason } from "./classify.js";
5
+ import { MAX_BUILD_TURNS } from "./constants.js";
6
+ import { PreflightError, reasonOf } from "./errors.js";
7
+ import { Git } from "./git.js";
8
+ import { Harness } from "./harness.js";
9
+ import { Logger } from "./logger.js";
10
+ import { planNext } from "./resume.js";
11
+ import { buildTurn, runStage } from "./stages.js";
12
+ // How many consecutive attempts that make NO forward progress before we stop. Forward progress
13
+ // resets it to zero so a long run only gives up when it is genuinely wedged, never on cumulative
14
+ // failures across hours of real work.
15
+ const MAX_STALE_ATTEMPTS = 3;
16
+ const BACKOFF_BASE = Duration.seconds(10);
17
+ const BACKOFF_CAP = Duration.minutes(5);
18
+ function backoff(attempt) {
19
+ const ms = Math.min(Duration.toMillis(BACKOFF_BASE) * 2 ** (attempt - 1), Duration.toMillis(BACKOFF_CAP));
20
+ return Duration.millis(ms);
57
21
  }
58
- function captureProgressMarker(cwd, planPath) {
59
- return {
60
- head: headCommit(cwd),
61
- planText: existsSync(planPath) ? readFileSync(planPath, "utf8") : ""
22
+ // The progress fingerprint: newest stage marker + HEAD's tree hash. An --allow-empty checkpoint
23
+ // changes neither the tree nor the marker (for a repeating build turn), so a turn that did nothing
24
+ // real cannot move it — which is exactly what makes "no forward progress" honest.
25
+ const fingerprint = (cwd) => Effect.gen(function* () {
26
+ const git = yield* Git;
27
+ const marker = yield* git.latestStage(cwd);
28
+ const tree = yield* git.treeHash(cwd);
29
+ return `${marker ?? "none"}:${tree}`;
30
+ });
31
+ const totalBuildTurns = (cwd) => Effect.gen(function* () {
32
+ const git = yield* Git;
33
+ return (yield* git.countStage(cwd, "build")) + (yield* git.countStage(cwd, "done"));
34
+ });
35
+ const note = (text) => Effect.flatMap(Logger, (logger) => logger.write(`[create] ${text}`));
36
+ export const run = (options) => Effect.gen(function* () {
37
+ const resolved = yield* resolveProject(options);
38
+ const harnessSvc = yield* Harness;
39
+ const harness = yield* harnessSvc.select(options.harness ?? resolved.harness);
40
+ const ctx = {
41
+ cwd: resolved.cwd,
42
+ prompt: resolved.prompt,
43
+ harness,
44
+ harnessArgs: options.harnessArgs ?? resolved.harnessArgs,
45
+ timeoutMs: options.harnessTimeoutMs ?? resolved.harnessTimeoutMs,
46
+ skipTemplate: options.skipTemplate ?? resolved.skipTemplate
62
47
  };
63
- }
64
- // HEAD's commit hash, or "" before the first commit exists. `git rev-parse HEAD` throws on an empty
65
- // repo, which we read as "no commit yet" rather than a failure.
66
- function headCommit(cwd) {
67
- try {
68
- return execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim();
48
+ const maxTurns = options.maxTurns ?? resolved.maxTurns ?? MAX_BUILD_TURNS;
49
+ // The metadata stamped onto the scaffold checkpoint so a later `--resume` reproduces this run.
50
+ const runMeta = {
51
+ prompt: ctx.prompt,
52
+ harness,
53
+ harnessArgs: ctx.harnessArgs,
54
+ harnessTimeoutMs: ctx.timeoutMs,
55
+ maxTurns,
56
+ skipTemplate: ctx.skipTemplate
57
+ };
58
+ return yield* loop(ctx, runMeta, maxTurns, options.backoff ?? backoff, {
59
+ staleAttempts: 0,
60
+ errors: []
61
+ });
62
+ });
63
+ const loop = (ctx, runMeta, maxTurns, backoffFor, state) => Effect.gen(function* () {
64
+ const git = yield* Git;
65
+ const before = yield* fingerprint(ctx.cwd);
66
+ const step = yield* planNext(ctx.cwd);
67
+ if (step._tag === "done") {
68
+ return finish(ctx.cwd, "done", { staleAttempts: 0, errors: [] }, yield* totalBuildTurns(ctx.cwd));
69
69
  }
70
- catch {
71
- return "";
70
+ const turns = yield* totalBuildTurns(ctx.cwd);
71
+ if (step.stage === "build" && turns >= maxTurns) {
72
+ return finish(ctx.cwd, "budget-exhausted", state, turns);
72
73
  }
73
- }
74
- function resetToLastGoodCommit(cwd) {
75
- execFileSync("git", ["reset", "--hard", "HEAD"], { cwd, stdio: "ignore" });
76
- execFileSync("git", ["clean", "-fd"], { cwd, stdio: "ignore" });
77
- }
78
- function done(cwd, turns, stop) {
79
- return { projectDir: cwd, turns, stop };
80
- }
81
- function logToSession(cwd, line) {
82
- try {
83
- appendFileSync(join(cwd, SESSION_LOG_FILE), `${line}\n`);
74
+ yield* Console.log(`→ ${label(step.stage, ctx.harness)}`);
75
+ const work = step.stage === "build"
76
+ ? buildTurn(ctx, turns + 1)
77
+ : runStage(step.stage, ctx, step.stage === "scaffold" ? runMeta : undefined);
78
+ const outcome = yield* Effect.either(work);
79
+ let reason;
80
+ if (outcome._tag === "Left") {
81
+ reason = reasonOf(outcome.left);
82
+ yield* Console.error(`✕ ${label(step.stage, ctx.harness)} — ${humanReason(reason)}`);
83
+ yield* note(`${step.stage} failed: ${humanReason(reason)}`);
84
+ // Resume only from a clean committed state. If even the reset fails (rare), surface it to the
85
+ // log and still loop — aborting the whole run over a failed cleanup would be worse, and a
86
+ // genuinely wedged tree is caught by the no-progress stop anyway.
87
+ yield* git
88
+ .resetClean(ctx.cwd)
89
+ .pipe(Effect.catchAll((failure) => note(`reset to clean state failed: ${failure.detail}`)));
90
+ }
91
+ const after = yield* fingerprint(ctx.cwd);
92
+ if (after !== before) {
93
+ if (outcome._tag === "Right")
94
+ yield* Console.log(`✓ ${label(step.stage, ctx.harness)}`);
95
+ // Forward progress — reset the stale counter and the error accumulator; only the failures that
96
+ // actually precede a stop should appear in its report.
97
+ return yield* loop(ctx, runMeta, maxTurns, backoffFor, { staleAttempts: 0, errors: [] });
84
98
  }
85
- catch {
86
- // No session log on disk (e.g. a test working dir) — the supervisor proceeds anyway.
99
+ const staleAttempts = state.staleAttempts + 1;
100
+ const errors = reason ? [...state.errors, reason] : state.errors;
101
+ if (outcome._tag === "Right")
102
+ yield* note(`${step.stage} made no changes`);
103
+ if (staleAttempts >= MAX_STALE_ATTEMPTS) {
104
+ return finish(ctx.cwd, "stuck", { staleAttempts, errors }, yield* totalBuildTurns(ctx.cwd));
87
105
  }
106
+ yield* note(`no progress; attempt ${staleAttempts}/${MAX_STALE_ATTEMPTS}, backing off`);
107
+ yield* Effect.sleep(backoffFor(staleAttempts));
108
+ return yield* loop(ctx, runMeta, maxTurns, backoffFor, { staleAttempts, errors });
109
+ });
110
+ const finish = (projectDir, outcome, state, buildTurns) => ({
111
+ projectDir,
112
+ outcome,
113
+ staleAttempts: state.staleAttempts,
114
+ errors: state.errors,
115
+ buildTurns
116
+ });
117
+ function label(stage, harness) {
118
+ return stage === "scaffold" ? "scaffolding" : `${stage} (${harness})`;
88
119
  }
89
- // Re-invoke this CLI's own build stage as a fresh child: `--stage build` runs exactly one turn
90
- // (create.ts turnBudget), so the heavy turn state lives and dies in the child, not in the
91
- // supervisor. cli.js sits next to this compiled module.
92
- function defaultRunBuildTurnChild(env, options) {
93
- const cliPath = fileURLToPath(new URL("./cli.js", import.meta.url));
94
- const runner = createSubprocessRunner({ env, stdio: "inherit" });
95
- const args = [cliPath, "--stage", "build"];
96
- if (options.harness)
97
- args.push("--harness", options.harness);
98
- if (options.harnessTimeoutMinutes !== undefined) {
99
- args.push("--harness-timeout-minutes", String(options.harnessTimeoutMinutes));
120
+ // A new run creates (or adopts, with --here) the project directory; a resume reads the run metadata
121
+ // back out of git history so it continues with nothing re-typed.
122
+ const resolveProject = (options) => options.resume ? resolveResume(options) : resolveNew(options);
123
+ const resolveResume = (options) => Effect.gen(function* () {
124
+ const git = yield* Git;
125
+ const cwd = resolve(options.cwd ?? process.cwd());
126
+ if (!existsSync(join(cwd, ".git"))) {
127
+ return yield* Effect.fail(new PreflightError({ message: `${cwd} is not a drawcall-create project (no .git)` }));
100
128
  }
101
- args.push(BUILD_RESUME_PROMPT);
102
- if (options.harnessArgs && options.harnessArgs.length > 0) {
103
- args.push("--", ...options.harnessArgs);
129
+ const meta = yield* git.readRunMeta(cwd);
130
+ const prompt = options.prompt ?? meta?.prompt;
131
+ if (!prompt) {
132
+ return yield* Effect.fail(new PreflightError({
133
+ message: `cannot resume ${cwd}: no recorded prompt — pass the prompt to resume this run`
134
+ }));
104
135
  }
105
- return (cwd) => runner({ command: process.execPath, args, cwd });
106
- }
107
- // The build stage re-reads the committed records (GOAL.md/PLAN.md/README.md) and continues the
108
- // plan, so the prompt is just a resume marker — the real instructions live in the build prompt the
109
- // child assembles. The CLI requires a non-empty prompt.
110
- const BUILD_RESUME_PROMPT = "resume the build from the committed plan";
136
+ return {
137
+ cwd,
138
+ prompt,
139
+ harness: meta?.harness,
140
+ harnessArgs: meta?.harnessArgs,
141
+ harnessTimeoutMs: meta?.harnessTimeoutMs,
142
+ maxTurns: meta?.maxTurns,
143
+ skipTemplate: meta?.skipTemplate
144
+ };
145
+ });
146
+ // The project directory is computed and created by the CLI before the Effect program starts (the log
147
+ // layer needs the path up front), so here we only validate the prompt and trust the resolved cwd.
148
+ const resolveNew = (options) => Effect.gen(function* () {
149
+ const prompt = options.prompt?.trim();
150
+ if (!prompt) {
151
+ return yield* Effect.fail(new PreflightError({ message: "a prompt is required for a new run" }));
152
+ }
153
+ return { cwd: resolve(options.cwd ?? process.cwd()), prompt };
154
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawcall/create",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Create projects with an installed local harness.",
6
6
  "license": "MIT",
@@ -35,6 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "commander": "^14.0.3",
38
+ "effect": "^3.21.4",
38
39
  "which": "^6.0.1"
39
40
  },
40
41
  "devDependencies": {
package/dist/create.d.ts DELETED
@@ -1,35 +0,0 @@
1
- import { type HarnessName, type Stage } from "./constants.js";
2
- import { type HarnessRunner } from "./harness.js";
3
- import { type CommandExists, type CommandRunner } from "./subprocess.js";
4
- import { ProgressLog } from "./progress-log.js";
5
- export type CreateProjectOptions = {
6
- cwd?: string;
7
- env?: NodeJS.ProcessEnv;
8
- stage?: Stage;
9
- harness?: HarnessName;
10
- harnessArgs?: string[];
11
- harnessTimeoutMs?: number;
12
- maxTurns?: number;
13
- projectName?: string;
14
- skipTemplate?: boolean;
15
- runner?: CommandRunner;
16
- commandExists?: CommandExists;
17
- };
18
- export type CreateProjectResult = {
19
- projectDir: string;
20
- projectName: string;
21
- harness: HarnessName;
22
- exitCode: number;
23
- turns: number;
24
- maxTurns: number;
25
- durationMs: number;
26
- };
27
- export declare function createProject(prompt: string, options?: CreateProjectOptions): Promise<CreateProjectResult>;
28
- /** Build-stage: loop build-turns against the plan up to the build-turn-budget. */
29
- export declare function runBuildTurns(harnessRunner: HarnessRunner, userPrompt: string, options?: {
30
- maxTurns?: number;
31
- progressLog?: ProgressLog;
32
- }): Promise<{
33
- exitCode: number;
34
- turns: number;
35
- }>;