@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.
package/dist/scaffold.js CHANGED
@@ -2,36 +2,100 @@ import { randomBytes } from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
3
  import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, join } from "node:path";
5
+ import { Context, Effect, Layer } from "effect";
6
+ import { classifyHarnessOutput } from "./classify.js";
5
7
  import { GITHUB_SKILLS, GITIGNORE_ENTRIES, PACKAGE_SKILLS, PACKAGES } from "./constants.js";
6
- import { assertExitCode } from "./subprocess.js";
8
+ import { FsFailure, StageFailure } from "./errors.js";
9
+ import { Shell } from "./shell.js";
7
10
  export function generateProjectName() {
8
11
  return `dc-${randomBytes(3).toString("hex")}`;
9
12
  }
10
- export async function initGitRepo(cwd, runner) {
11
- await assertExitCode(runner({ command: "git", args: ["init"], cwd }), "failed to initialise git repository");
12
- }
13
- export async function commitAll(cwd, runner, message) {
14
- await assertExitCode(runner({ command: "git", args: ["add", "-A"], cwd }), "failed to stage changes");
15
- // A build turn often commits its own work mid-turn (the harness runs git itself). When it does,
16
- // the staged tree is already clean here and an --allow-empty commit would mint a second, empty
17
- // "feat: build turn N" — so commit count drifts above turn count. Only commit when something is
18
- // actually staged; otherwise the turn's own commit already records this turn's completion.
19
- if (await isTreeClean(cwd, runner))
20
- return;
21
- await assertExitCode(runner({ command: "git", args: ["commit", "-m", message], cwd }), "failed to commit changes");
22
- }
23
- // True when `git add -A` left nothing staged (a clean index against HEAD). `git diff --cached
24
- // --quiet` exits 0 when there are no staged changes and 1 when there are, which is exactly the
25
- // signal we want — so we read its exit code rather than asserting it.
26
- async function isTreeClean(cwd, runner) {
27
- const { exitCode } = await runner({
28
- command: "git",
29
- args: ["diff", "--cached", "--quiet"],
30
- cwd
13
+ // Our harness names don't all match the `skills` CLI's agent ids.
14
+ const SKILLS_AGENT = {
15
+ opencode: "opencode",
16
+ codex: "codex",
17
+ claude: "claude-code",
18
+ pi: "pi",
19
+ gemini: "gemini-cli",
20
+ grok: "universal",
21
+ forge: "universal"
22
+ };
23
+ export class Scaffold extends Context.Tag("Scaffold")() {
24
+ }
25
+ const fail = (label, output, exitCode, timedOut) => new StageFailure({
26
+ stage: "scaffold",
27
+ reason: classifyHarnessOutput(output, exitCode, timedOut),
28
+ detail: `${label}: ${output.slice(-400)}`
29
+ });
30
+ const live = (shell) => {
31
+ const fsOp = (op, path, run) => Effect.tryPromise({
32
+ try: run,
33
+ catch: (e) => new FsFailure({ op, path, detail: String(e) })
31
34
  });
32
- return exitCode === 0;
35
+ const step = (cwd, label, command, args) => shell
36
+ .run({ command, args, cwd })
37
+ .pipe(Effect.flatMap((r) => r.exitCode === 0
38
+ ? Effect.void
39
+ : Effect.fail(fail(label, r.output, r.exitCode, r.timedOut))));
40
+ return {
41
+ run: (cwd, harness) => Effect.gen(function* () {
42
+ yield* fsOp("seed", cwd, () => seedProjectFiles(cwd));
43
+ // The curated set installs together; their peer ranges drift, and modern npm aborts on the
44
+ // first mismatch — so install as-specified and let the pinned versions stand.
45
+ yield* step(cwd, "npm install", "npm", ["install", "--legacy-peer-deps", ...PACKAGES]);
46
+ yield* fsOp("shims", cwd, () => ensureLocalCliShims(cwd));
47
+ // Dependencies first: PACKAGE_SKILLS install from their package directory under node_modules,
48
+ // which only exists once dependencies are installed.
49
+ const agent = SKILLS_AGENT[harness];
50
+ for (const skill of GITHUB_SKILLS) {
51
+ yield* step(cwd, `skills add ${skill}`, "npx", [
52
+ "--yes",
53
+ "skills",
54
+ "add",
55
+ skill,
56
+ "-y",
57
+ "--agent",
58
+ agent
59
+ ]);
60
+ }
61
+ for (const pkg of PACKAGE_SKILLS) {
62
+ // Absolute path so the source is unambiguous (a bare "node_modules/@x/y" reads as a GitHub
63
+ // owner/repo slug).
64
+ yield* step(cwd, `skills add ${pkg}`, "npx", [
65
+ "--yes",
66
+ "skills",
67
+ "add",
68
+ join(cwd, "node_modules", pkg),
69
+ "-y",
70
+ "--agent",
71
+ agent
72
+ ]);
73
+ }
74
+ })
75
+ };
76
+ };
77
+ export const ScaffoldLive = Layer.effect(Scaffold, Effect.map(Shell, (shell) => live(shell)));
78
+ // --- file seeding ----------------------------------------------------------
79
+ async function seedProjectFiles(cwd) {
80
+ await ensureProjectGitignore(cwd);
81
+ await ensureNpmrc(cwd);
82
+ for (const directoryName of ["src", "public"]) {
83
+ const directory = join(cwd, directoryName);
84
+ await mkdir(directory, { recursive: true });
85
+ const placeholder = join(directory, ".gitkeep");
86
+ if (!existsSync(placeholder))
87
+ await writeFile(placeholder, "");
88
+ }
89
+ await initNpmProject(cwd);
90
+ }
91
+ function toPackageName(name) {
92
+ const normalized = name
93
+ .toLowerCase()
94
+ .replace(/[^a-z0-9._~-]+/g, "-")
95
+ .replace(/^-+|-+$/g, "");
96
+ return normalized || "drawcall-project";
33
97
  }
34
- export async function initNpmProject(cwd, _runner) {
98
+ async function initNpmProject(cwd) {
35
99
  const packageJsonPath = join(cwd, "package.json");
36
100
  if (existsSync(packageJsonPath))
37
101
  return;
@@ -39,146 +103,47 @@ export async function initNpmProject(cwd, _runner) {
39
103
  name: toPackageName(basename(cwd)),
40
104
  version: "1.0.0",
41
105
  private: true,
42
- // Seed runnable scripts so every project is launchable from turn 1 (Vite is the pinned
43
- // bundler). Build turns serve via `npm run dev` and bundle via `npm run build`; without
44
- // these, weaker harnesses ship a project with no documented way to run the game.
45
106
  type: "module",
46
- scripts: {
47
- dev: "vite",
48
- build: "vite build",
49
- preview: "vite preview"
50
- }
107
+ scripts: { dev: "vite", build: "vite build", preview: "vite preview" }
51
108
  }, null, 2)}\n`);
52
109
  }
53
- function toPackageName(name) {
54
- const normalized = name
55
- .toLowerCase()
56
- .replace(/[^a-z0-9._~-]+/g, "-")
57
- .replace(/^-+|-+$/g, "");
58
- return normalized || "drawcall-project";
59
- }
60
- export async function ensureProjectGitignore(cwd) {
110
+ async function ensureProjectGitignore(cwd) {
61
111
  const gitignorePath = join(cwd, ".gitignore");
62
112
  const existing = existsSync(gitignorePath) ? await readFile(gitignorePath, "utf8") : "";
63
113
  const existingEntries = new Set(existing
64
114
  .split(/\r?\n/)
65
115
  .map((line) => line.trim())
66
116
  .filter((line) => line && !line.startsWith("#")));
67
- const missingEntries = GITIGNORE_ENTRIES.filter((entry) => !existingEntries.has(entry));
68
- if (missingEntries.length === 0)
117
+ const missing = GITIGNORE_ENTRIES.filter((entry) => !existingEntries.has(entry));
118
+ if (missing.length === 0)
69
119
  return;
70
- const prefix = existing.trimEnd();
71
- const nextContent = [prefix, ...missingEntries].filter(Boolean).join("\n") + "\n";
72
- await writeFile(gitignorePath, nextContent);
120
+ const next = [existing.trimEnd(), ...missing].filter(Boolean).join("\n") + "\n";
121
+ await writeFile(gitignorePath, next);
73
122
  }
74
- // The curated package set pins a recent `three`, but some Drawcall packages declare peer ranges
75
- // that lag a minor or two (e.g. @drawcall/flipbook peers an older three). The scaffold's own
76
- // install passes `--legacy-peer-deps`, but later installs — a harness running `npm install`, or
77
- // `market install` applying a template — run plain and would abort on the first peer mismatch.
78
- // A persisted `.npmrc` makes the whole project tolerant so those installs don't veto a pinned
79
- // version over a peer-range lag.
80
- export async function ensureNpmrc(cwd) {
123
+ async function ensureNpmrc(cwd) {
81
124
  const npmrcPath = join(cwd, ".npmrc");
82
125
  const existing = existsSync(npmrcPath) ? await readFile(npmrcPath, "utf8") : "";
83
126
  if (/^\s*legacy-peer-deps\s*=/m.test(existing))
84
127
  return;
85
- const prefix = existing.trimEnd();
86
- const nextContent = [prefix, "legacy-peer-deps=true"].filter(Boolean).join("\n") + "\n";
87
- await writeFile(npmrcPath, nextContent);
128
+ const next = [existing.trimEnd(), "legacy-peer-deps=true"].filter(Boolean).join("\n") + "\n";
129
+ await writeFile(npmrcPath, next);
88
130
  }
89
- export async function ensureBaseProjectDirectories(cwd) {
90
- for (const directoryName of ["src", "public"]) {
91
- const directory = join(cwd, directoryName);
92
- await mkdir(directory, { recursive: true });
93
- const placeholder = join(directory, ".gitkeep");
94
- if (!existsSync(placeholder))
95
- await writeFile(placeholder, "");
96
- }
97
- }
98
- // Our harness names don't all match the `skills` CLI's agent ids.
99
- const SKILLS_AGENT = {
100
- opencode: "opencode",
101
- codex: "codex",
102
- claude: "claude-code",
103
- pi: "pi",
104
- gemini: "gemini-cli",
105
- // The skills CLI has no dedicated grok agent; "universal" installs to .agents/skills, which
106
- // grok discovers. Verify skill discovery in grok's e2e run and adjust if it misses them.
107
- grok: "universal",
108
- // Likewise no dedicated forge agent; "universal" (.agents/skills) is the portable target.
109
- // Verify forge actually discovers the installed skills in its e2e run and adjust if it misses.
110
- forge: "universal"
111
- };
112
- // Must run after installDependencies: the PACKAGE_SKILLS are installed from their package directory
113
- // under node_modules, which only exists once dependencies are installed.
114
- export async function installSkills(cwd, harness, runner) {
115
- const agent = SKILLS_AGENT[harness];
116
- const addSkill = (source, label) => assertExitCode(runner({
117
- command: "npx",
118
- args: ["--yes", "skills", "add", source, "-y", "--agent", agent],
119
- cwd
120
- }), `failed to install skill ${label}`);
121
- for (const skill of GITHUB_SKILLS) {
122
- await addSkill(skill, skill);
123
- }
124
- // Install the private-repo skills from their already-installed npm package (see PACKAGE_SKILLS).
125
- // An absolute path is unambiguous — a bare "node_modules/@drawcall/acta" would be read as an
126
- // owner/repo GitHub slug.
127
- for (const pkg of PACKAGE_SKILLS) {
128
- await addSkill(join(cwd, "node_modules", pkg), pkg);
129
- }
130
- }
131
- export async function installDependencies(cwd, runner) {
132
- // This is a fixed, curated set of packages we install together; their peer ranges drift
133
- // independently (e.g. a runtime that pins an older `three` minor), and modern npm aborts the
134
- // whole install on the first peer mismatch. We don't want a generated project to fail to
135
- // scaffold over a peer-range lag in one package, so we install the set as-specified and let
136
- // the pinned versions stand rather than letting peer resolution veto them.
137
- await assertExitCode(runner({ command: "npm", args: ["install", "--legacy-peer-deps", ...PACKAGES], cwd }), "failed to install dependencies");
138
- await ensureLocalCliShims(cwd);
139
- }
140
- // Called after the template stage: an applied starter may already ship a README, so this only
141
- // creates a minimal state-record when none exists. The fixed goal lives in GOAL.md; the plan
142
- // lives in PLAN.md.
143
- export async function ensureStateReadme(cwd) {
144
- const readmePath = join(cwd, "README.md");
145
- if (existsSync(readmePath))
146
- return;
147
- await writeFile(readmePath, `# Project State
148
-
149
- This is the state-record: what the product actually is right now. The fixed goal
150
- lives in GOAL.md; the plan toward it lives in PLAN.md once planning has run.
151
-
152
- ## Verified State
153
-
154
- - Nothing verified yet — the project was just scaffolded.
155
-
156
- ## Next Step
157
-
158
- - Survey the fitting installed skills, packages, and Market assets, then plan and build the first real slice toward the full goal without shrinking it into a prototype.
159
-
160
- ## Proof Log
161
-
162
- - No proof-run has been recorded yet.
163
- `);
164
- }
165
- // --- Installed-package shims -------------------------------------------------
166
- // The @drawcall/acta and @drawcall/market packages ship CLIs/install logic that
167
- // assume a different layout than a freshly-generated project. These helpers
168
- // patch the installed copies in node_modules so their bins resolve locally.
169
- export async function ensureLocalCliShims(cwd) {
131
+ // --- installed-package shims ----------------------------------------------
132
+ // @drawcall/acta and @drawcall/market ship CLIs/install logic assuming a different layout than a
133
+ // freshly generated project. Patch the installed copies so their bins resolve locally.
134
+ async function ensureLocalCliShims(cwd) {
170
135
  await ensureActaCliShim(cwd);
171
136
  await ensureMarketCliShim(cwd);
172
137
  }
173
- export async function ensureMarketCliShim(cwd) {
138
+ async function ensureMarketCliShim(cwd) {
174
139
  const packageRoot = join(cwd, "node_modules", "@drawcall", "market");
175
140
  await rewriteMarketInstallRoot(join(packageRoot, "dist", "install.js"));
176
141
  await rewriteMarketInstallRoot(join(packageRoot, "src", "install.ts"));
177
142
  }
178
- export async function ensureActaCliShim(cwd) {
143
+ async function ensureActaCliShim(cwd) {
179
144
  const packageRoot = join(cwd, "node_modules", "@drawcall", "acta");
180
- const devShim = join(packageRoot, "src", "cli", "dev.ts");
181
145
  const sourceCli = join(packageRoot, "src", "cli", "index.js");
146
+ const devShim = join(packageRoot, "src", "cli", "dev.ts");
182
147
  const distCli = join(packageRoot, "dist", "cli", "index.js");
183
148
  const packageJsonPath = join(packageRoot, "package.json");
184
149
  const binShim = join(cwd, "node_modules", ".bin", "acta");
@@ -190,33 +155,23 @@ export async function ensureActaCliShim(cwd) {
190
155
  await writeFile(devShim, "#!/usr/bin/env node\nimport '../../dist/cli/index.js';\n");
191
156
  await chmod(devShim, 0o755);
192
157
  }
193
- await rewriteActaPackageBin(packageJsonPath);
194
- await rewriteActaPackageEntrypoint(packageJsonPath);
158
+ await rewriteActaPackageJson(packageJsonPath);
195
159
  await mkdir(dirname(binShim), { recursive: true });
196
160
  await rm(binShim, { force: true });
197
161
  await writeFile(binShim, "#!/usr/bin/env node\nimport '../@drawcall/acta/dist/cli/index.js';\n");
198
162
  await chmod(binShim, 0o755);
199
163
  }
200
- async function rewriteActaPackageBin(packageJsonPath) {
164
+ async function rewriteActaPackageJson(packageJsonPath) {
201
165
  if (!existsSync(packageJsonPath))
202
166
  return;
203
167
  const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
204
168
  const existingBin = packageJson.bin && typeof packageJson.bin === "object" ? packageJson.bin : {};
205
169
  packageJson.bin = { ...existingBin, acta: "dist/cli/index.js" };
206
- await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
207
- }
208
- async function rewriteActaPackageEntrypoint(packageJsonPath) {
209
- if (!existsSync(packageJsonPath))
210
- return;
211
- const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
212
170
  packageJson.main = "dist/index.js";
213
171
  packageJson.types = "dist/index.d.ts";
214
172
  packageJson.exports = {
215
173
  ...(packageJson.exports ?? {}),
216
- ".": {
217
- types: "./dist/index.d.ts",
218
- import: "./dist/index.js"
219
- }
174
+ ".": { types: "./dist/index.d.ts", import: "./dist/index.js" }
220
175
  };
221
176
  await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
222
177
  }
@@ -241,7 +196,6 @@ async function rewriteMarketInstallRoot(filePath) {
241
196
 
242
197
  async function downloadAssets`;
243
198
  const patched = source.replace(/export async function findInstallRoot[\s\S]*?\n}\n\n?async function downloadAssets/, replacement);
244
- if (patched !== source) {
199
+ if (patched !== source)
245
200
  await writeFile(filePath, patched);
246
- }
247
201
  }
@@ -0,0 +1,22 @@
1
+ import { Context, Effect, Layer } from "effect";
2
+ import { Logger } from "./logger.js";
3
+ export interface CommandSpec {
4
+ readonly command: string;
5
+ readonly args: ReadonlyArray<string>;
6
+ readonly cwd: string;
7
+ readonly timeoutMs?: number;
8
+ }
9
+ export interface CommandResult {
10
+ readonly exitCode: number;
11
+ readonly output: string;
12
+ readonly timedOut: boolean;
13
+ }
14
+ export interface ShellService {
15
+ readonly run: (spec: CommandSpec) => Effect.Effect<CommandResult>;
16
+ readonly exists: (command: string) => Effect.Effect<boolean>;
17
+ }
18
+ declare const Shell_base: Context.TagClass<Shell, "Shell", ShellService>;
19
+ export declare class Shell extends Shell_base {
20
+ }
21
+ export declare const ShellLive: (env?: NodeJS.ProcessEnv) => Layer.Layer<Shell, never, Logger>;
22
+ export {};
package/dist/shell.js ADDED
@@ -0,0 +1,114 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createInterface } from "node:readline";
3
+ import { delimiter, dirname, join, resolve } from "node:path";
4
+ import which from "which";
5
+ import { Context, Effect, Layer } from "effect";
6
+ import { TIMEOUT_EXIT_CODE, TIMEOUT_KILL_GRACE_MS } from "./constants.js";
7
+ import { Logger } from "./logger.js";
8
+ export class Shell extends Context.Tag("Shell")() {
9
+ }
10
+ // Prepend the project's local bin and fence git's directory walk to the project's parent, so a
11
+ // harness spawned in the project resolves project-local CLIs and never escapes upward to a parent
12
+ // repo. (Kept verbatim from the original runner — this is hard-won and not Effect-specific.)
13
+ function buildSubprocessEnv(cwd, baseEnv = process.env) {
14
+ const projectParent = dirname(resolve(cwd));
15
+ const localBin = join(resolve(cwd), "node_modules", ".bin");
16
+ return {
17
+ ...baseEnv,
18
+ PATH: baseEnv.PATH ? `${localBin}${delimiter}${baseEnv.PATH}` : localBin,
19
+ GIT_CEILING_DIRECTORIES: baseEnv.GIT_CEILING_DIRECTORIES
20
+ ? `${projectParent}${delimiter}${baseEnv.GIT_CEILING_DIRECTORIES}`
21
+ : projectParent
22
+ };
23
+ }
24
+ // Signals never reach a detached child group directly, so tear the whole tree down explicitly. On
25
+ // win32 there are no process groups; taskkill /T removes the tree.
26
+ function killTree(child, detached, signal) {
27
+ const pid = child.pid;
28
+ if (!pid)
29
+ return;
30
+ if (process.platform === "win32") {
31
+ spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" }).unref();
32
+ return;
33
+ }
34
+ try {
35
+ process.kill(detached ? -pid : pid, signal);
36
+ }
37
+ catch {
38
+ /* already exited between the timeout firing and this kill */
39
+ }
40
+ }
41
+ const live = (env, logger) => ({
42
+ exists: (command) => Effect.promise(async () => {
43
+ const resolved = await which(command, {
44
+ nothrow: true,
45
+ path: env.PATH,
46
+ pathExt: env.PATHEXT
47
+ });
48
+ return resolved !== null;
49
+ }),
50
+ run: ({ command, args, cwd, timeoutMs }) =>
51
+ // Effect.async so an outer interruption (Ctrl-C, an enclosing timeout) runs the returned
52
+ // finalizer and kills the child tree — the heavy harness process can never outlive its fiber.
53
+ Effect.async((resume) => {
54
+ const startedAt = Date.now();
55
+ const detached = process.platform !== "win32";
56
+ const captured = [];
57
+ // The Logger is synchronous (it appends to the hidden log); runSync bridges it into node's
58
+ // event callbacks below, where we cannot await.
59
+ const log = (text) => Effect.runSync(logger.write(text));
60
+ log(`\n$ ${[command, ...args].join(" ")}`);
61
+ const child = spawn(command, [...args], {
62
+ cwd,
63
+ detached,
64
+ env: buildSubprocessEnv(cwd, env),
65
+ stdio: ["ignore", "pipe", "pipe"]
66
+ });
67
+ // Read both streams a line at a time so a timestamp only ever prefixes a whole line, and keep
68
+ // every line for classification.
69
+ for (const stream of [child.stdout, child.stderr]) {
70
+ if (!stream)
71
+ continue;
72
+ createInterface({ input: stream }).on("line", (line) => {
73
+ captured.push(line);
74
+ Effect.runSync(logger.captured(line));
75
+ });
76
+ }
77
+ let timedOut = false;
78
+ let killTimer;
79
+ const timeout = timeoutMs === undefined
80
+ ? undefined
81
+ : setTimeout(() => {
82
+ timedOut = true;
83
+ log(`[create] ${command} timed out after ${Math.ceil(timeoutMs / 1000)}s`);
84
+ killTree(child, detached, "SIGTERM");
85
+ killTimer = setTimeout(() => killTree(child, detached, "SIGKILL"), TIMEOUT_KILL_GRACE_MS);
86
+ }, timeoutMs);
87
+ const cleanup = () => {
88
+ if (timeout)
89
+ clearTimeout(timeout);
90
+ if (killTimer)
91
+ clearTimeout(killTimer);
92
+ };
93
+ child.once("error", (error) => {
94
+ cleanup();
95
+ // spawn failed (command not found, etc.): surface as a non-zero exit with the error text so
96
+ // the caller can classify it like any other failure.
97
+ captured.push(String(error.message ?? error));
98
+ resume(Effect.succeed({ exitCode: 127, output: captured.join("\n"), timedOut }));
99
+ });
100
+ child.once("exit", (code, signal) => {
101
+ cleanup();
102
+ const exitCode = timedOut ? TIMEOUT_EXIT_CODE : signal ? 1 : (code ?? 1);
103
+ const elapsed = Math.round((Date.now() - startedAt) / 1000);
104
+ const failure = !timedOut && exitCode !== 0 ? ` — exit ${exitCode}` : "";
105
+ log(`[create] ${command} done in ${elapsed}s${failure}`);
106
+ resume(Effect.succeed({ exitCode, output: captured.join("\n"), timedOut }));
107
+ });
108
+ return Effect.sync(() => {
109
+ cleanup();
110
+ killTree(child, detached, "SIGKILL");
111
+ });
112
+ })
113
+ });
114
+ export const ShellLive = (env = process.env) => Layer.effect(Shell, Effect.map(Logger, (logger) => live(env, logger)));
@@ -0,0 +1,18 @@
1
+ import { Effect } from "effect";
2
+ import { type HarnessName, type PipelineStage } from "./constants.js";
3
+ import { type RunFailure } from "./errors.js";
4
+ import { Git, type RunMeta } from "./git.js";
5
+ import { Harness } from "./harness.js";
6
+ import { Scaffold } from "./scaffold.js";
7
+ export interface RunContext {
8
+ readonly cwd: string;
9
+ readonly prompt: string;
10
+ readonly harness: HarnessName;
11
+ readonly harnessArgs?: ReadonlyArray<string>;
12
+ readonly timeoutMs?: number;
13
+ readonly skipTemplate?: boolean;
14
+ }
15
+ type StageEnv = Harness | Scaffold | Git;
16
+ export declare const runStage: (stage: Exclude<PipelineStage, "build">, ctx: RunContext, runMeta: RunMeta | undefined) => Effect.Effect<void, RunFailure, StageEnv>;
17
+ export declare const buildTurn: (ctx: RunContext, turnNumber: number) => Effect.Effect<void, RunFailure, StageEnv>;
18
+ export {};
package/dist/stages.js ADDED
@@ -0,0 +1,120 @@
1
+ import { existsSync } from "node:fs";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { Effect } from "effect";
5
+ import { classifyHarnessOutput } from "./classify.js";
6
+ import { GOAL_FILE, PLAN_FILE, README_FILE } from "./constants.js";
7
+ import { FsFailure, StageFailure } from "./errors.js";
8
+ import { Git } from "./git.js";
9
+ import { Harness } from "./harness.js";
10
+ import { Scaffold } from "./scaffold.js";
11
+ import { buildBuildPrompt, buildGoalPrompt, buildPlanPrompt, buildSurveyAssetsPrompt, buildSurveyTechnologyPrompt, buildTemplatePrompt } from "./prompts.js";
12
+ // Run one harness turn for a stage, turning a non-zero exit / timeout into a classified, explainable
13
+ // StageFailure. A clean exit is success — the stage's own assertions (did GOAL.md appear?) catch the
14
+ // "ran fine but produced nothing" case separately.
15
+ const turn = (stage, prompt, ctx) => Effect.flatMap(Harness, (harness) => harness
16
+ .runTurn({
17
+ harness: ctx.harness,
18
+ prompt,
19
+ cwd: ctx.cwd,
20
+ harnessArgs: ctx.harnessArgs,
21
+ timeoutMs: ctx.timeoutMs
22
+ })
23
+ .pipe(Effect.flatMap((r) => r.exitCode === 0
24
+ ? Effect.void
25
+ : Effect.fail(new StageFailure({
26
+ stage,
27
+ reason: classifyHarnessOutput(r.output, r.exitCode, r.timedOut),
28
+ detail: r.output.slice(-400)
29
+ })))));
30
+ const assertProduced = (ctx, file, stage) => existsSync(join(ctx.cwd, file))
31
+ ? Effect.void
32
+ : Effect.fail(new StageFailure({
33
+ stage,
34
+ reason: { _tag: "Local", op: `${stage} output`, detail: `did not create ${file}` },
35
+ detail: `harness finished without creating ${file}`
36
+ }));
37
+ // Templates may ship their own README; only seed the minimal state-record when none exists.
38
+ const ensureStateReadme = (cwd) => {
39
+ const path = join(cwd, README_FILE);
40
+ if (existsSync(path))
41
+ return Effect.void;
42
+ return Effect.tryPromise({
43
+ try: () => writeFile(path, `# Project State
44
+
45
+ This is the state-record: what the product actually is right now. The fixed goal
46
+ lives in GOAL.md; the plan toward it lives in PLAN.md once planning has run.
47
+
48
+ ## Verified State
49
+
50
+ - Nothing verified yet — the project was just scaffolded.
51
+
52
+ ## Next Step
53
+
54
+ - Survey the fitting installed skills, packages, and Market assets, then plan and build the first real slice toward the full goal without shrinking it into a prototype.
55
+ `),
56
+ catch: (e) => new FsFailure({ op: "write", path, detail: String(e) })
57
+ });
58
+ };
59
+ const checkpoint = (cwd, stage) => Effect.flatMap(Git, (git) => git.checkpoint(cwd, stage));
60
+ // --- the stages ------------------------------------------------------------
61
+ const scaffold = (ctx, runMeta) => Effect.gen(function* () {
62
+ const git = yield* Git;
63
+ const scaffolder = yield* Scaffold;
64
+ yield* git.init(ctx.cwd);
65
+ yield* scaffolder.run(ctx.cwd, ctx.harness);
66
+ yield* git.checkpoint(ctx.cwd, "scaffold", runMeta);
67
+ });
68
+ const template = (ctx) => Effect.gen(function* () {
69
+ // A skip-template run starts from a bare scaffold; otherwise apply a fitting Market starter.
70
+ if (!ctx.skipTemplate)
71
+ yield* turn("template", buildTemplatePrompt(ctx.prompt), ctx);
72
+ yield* ensureStateReadme(ctx.cwd);
73
+ yield* checkpoint(ctx.cwd, "template");
74
+ });
75
+ // opencode contends over one per-project session when several instances share a dir, so its surveys
76
+ // run serially; every other harness runs the two concurrently.
77
+ const surveys = (ctx) => Effect.gen(function* () {
78
+ const both = [
79
+ turn("surveys", buildSurveyAssetsPrompt(ctx.prompt), ctx),
80
+ turn("surveys", buildSurveyTechnologyPrompt(ctx.prompt), ctx)
81
+ ];
82
+ yield* Effect.all(both, { concurrency: ctx.harness === "opencode" ? 1 : "unbounded" });
83
+ yield* checkpoint(ctx.cwd, "surveys");
84
+ });
85
+ const goal = (ctx) => Effect.gen(function* () {
86
+ yield* turn("goal", buildGoalPrompt(ctx.prompt), ctx);
87
+ yield* assertProduced(ctx, GOAL_FILE, "goal");
88
+ yield* checkpoint(ctx.cwd, "goal");
89
+ });
90
+ const plan = (ctx) => Effect.gen(function* () {
91
+ yield* turn("plan", buildPlanPrompt(ctx.prompt), ctx);
92
+ yield* assertProduced(ctx, PLAN_FILE, "plan");
93
+ yield* checkpoint(ctx.cwd, "plan");
94
+ });
95
+ // One build turn. The harness re-reads GOAL/PLAN/README and continues the plan; when the plan is
96
+ // genuinely consumed it deletes PLAN.md, which is our "done" signal. The checkpoint is --allow-empty,
97
+ // so a turn that committed real work and a turn that did nothing both leave a marker — and the
98
+ // supervisor tells them apart by the tree hash, not by whether a commit appeared.
99
+ const build = (ctx, turnNumber) => Effect.gen(function* () {
100
+ yield* turn("build", buildBuildPrompt(ctx.prompt, turnNumber), ctx);
101
+ const done = !existsSync(join(ctx.cwd, PLAN_FILE));
102
+ yield* checkpoint(ctx.cwd, done ? "done" : "build");
103
+ });
104
+ // Dispatch a non-build stage. Build is driven directly by the supervisor (it owns the turn number
105
+ // and budget), so it is intentionally excluded from this signature.
106
+ export const runStage = (stage, ctx, runMeta) => {
107
+ switch (stage) {
108
+ case "scaffold":
109
+ return scaffold(ctx, runMeta);
110
+ case "template":
111
+ return template(ctx);
112
+ case "surveys":
113
+ return surveys(ctx);
114
+ case "goal":
115
+ return goal(ctx);
116
+ case "plan":
117
+ return plan(ctx);
118
+ }
119
+ };
120
+ export const buildTurn = (ctx, turnNumber) => build(ctx, turnNumber);