@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/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 +114 -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/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 {
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
68
|
-
if (
|
|
117
|
+
const missing = GITIGNORE_ENTRIES.filter((entry) => !existingEntries.has(entry));
|
|
118
|
+
if (missing.length === 0)
|
|
69
119
|
return;
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
await writeFile(gitignorePath, nextContent);
|
|
120
|
+
const next = [existing.trimEnd(), ...missing].filter(Boolean).join("\n") + "\n";
|
|
121
|
+
await writeFile(gitignorePath, next);
|
|
73
122
|
}
|
|
74
|
-
|
|
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
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/shell.d.ts
ADDED
|
@@ -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)));
|
package/dist/stages.d.ts
ADDED
|
@@ -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);
|