@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/supervisor.d.ts
CHANGED
|
@@ -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 {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 {};
|
package/dist/supervisor.js
CHANGED
|
@@ -1,110 +1,154 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
3
2
|
import { join, resolve } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
+
"version": "0.2.1",
|
|
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
|
-
}>;
|