@drawcall/create 0.0.0 → 0.1.2
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/command.d.ts +2 -0
- package/dist/command.js +26 -3
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +13 -5
- package/dist/create.js +43 -13
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/prompts.js +23 -14
- package/dist/scaffold.js +18 -1
- package/dist/subprocess.js +42 -5
- package/dist/supervisor.d.ts +28 -0
- package/dist/supervisor.js +110 -0
- package/package.json +12 -11
package/dist/command.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { type HarnessName, type Stage } from "./constants.js";
|
|
3
3
|
import { createProject } from "./create.js";
|
|
4
|
+
import { superviseBuild } from "./supervisor.js";
|
|
4
5
|
export declare function createCreateCommand(command?: Command, options?: {
|
|
5
6
|
version?: string;
|
|
6
7
|
createProject?: typeof createProject;
|
|
8
|
+
superviseBuild?: typeof superviseBuild;
|
|
7
9
|
}): Command;
|
|
8
10
|
export declare function splitHarnessArgs(args: string[]): {
|
|
9
11
|
promptParts: string[];
|
package/dist/command.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { CliError, DEFAULT_HARNESS_TIMEOUT_MS, HARNESS_NAMES, MAX_BUILD_TURNS, STAGES } from "./constants.js";
|
|
3
3
|
import { createProject } from "./create.js";
|
|
4
|
+
import { superviseBuild } from "./supervisor.js";
|
|
4
5
|
import { formatDuration } from "./progress-log.js";
|
|
5
6
|
const CLI_OPTION_NAMES = [
|
|
6
7
|
"--stage",
|
|
@@ -8,24 +9,38 @@ const CLI_OPTION_NAMES = [
|
|
|
8
9
|
"--harness-timeout-minutes",
|
|
9
10
|
"--max-turns",
|
|
10
11
|
"--name",
|
|
11
|
-
"--skip-template"
|
|
12
|
+
"--skip-template",
|
|
13
|
+
"--supervise"
|
|
12
14
|
];
|
|
13
15
|
export function createCreateCommand(command = new Command(), options = {}) {
|
|
14
16
|
const create = options.createProject ?? createProject;
|
|
17
|
+
const supervise = options.superviseBuild ?? superviseBuild;
|
|
15
18
|
command
|
|
16
19
|
.name("drawcall-create")
|
|
17
20
|
.description("Create a project with an installed local harness")
|
|
18
|
-
.argument("
|
|
21
|
+
.argument("[args...]", "what should be created; use -- to pass following args to the harness (omit with --supervise)")
|
|
19
22
|
.option("--stage <name>", `which stage to run (${STAGES.join(", ")})`)
|
|
20
23
|
.option("--harness <name>", `harness to use (${HARNESS_NAMES.join(", ")})`)
|
|
21
24
|
.option("--harness-timeout-minutes <count>", `timeout for each harness invocation in minutes (default: ${DEFAULT_HARNESS_TIMEOUT_MS / 60_000})`)
|
|
22
25
|
.option("--max-turns <count>", `maximum build turns (default: ${MAX_BUILD_TURNS})`)
|
|
23
26
|
.option("--name <name>", "project directory name (default: a generated dc-xxxxxx name)")
|
|
24
27
|
.option("--skip-template", "during a full run, skip starter template search and build from scratch")
|
|
28
|
+
.option("--supervise", "run only the build loop, crash-safe: each turn is a separate child process, the tree is reset to the last good commit before every turn, and a killed turn resumes automatically")
|
|
25
29
|
.passThroughOptions()
|
|
26
30
|
.version(options.version ?? "0.0.0")
|
|
27
|
-
.action(async (args, commandOptions) => {
|
|
31
|
+
.action(async (args = [], commandOptions) => {
|
|
28
32
|
const { promptParts, harnessArgs } = splitHarnessArgs(args);
|
|
33
|
+
if (commandOptions.supervise === true) {
|
|
34
|
+
const result = await supervise({
|
|
35
|
+
harness: parseHarnessName(commandOptions.harness),
|
|
36
|
+
harnessArgs,
|
|
37
|
+
harnessTimeoutMinutes: parsePositiveInteger(commandOptions.harnessTimeoutMinutes, "--harness-timeout-minutes"),
|
|
38
|
+
maxTurns: parsePositiveInteger(commandOptions.maxTurns, "--max-turns")
|
|
39
|
+
});
|
|
40
|
+
console.log(formatSupervised(result));
|
|
41
|
+
process.exitCode = result.stop === "stuck" ? 1 : 0;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
29
44
|
const prompt = parsePromptParts(promptParts);
|
|
30
45
|
const result = await create(prompt, {
|
|
31
46
|
stage: parseStage(commandOptions.stage),
|
|
@@ -91,6 +106,14 @@ export function parseHarnessTimeoutMs(value) {
|
|
|
91
106
|
const minutes = parsePositiveInteger(value, "--harness-timeout-minutes");
|
|
92
107
|
return minutes === undefined ? undefined : minutes * 60_000;
|
|
93
108
|
}
|
|
109
|
+
function formatSupervised(result) {
|
|
110
|
+
const reason = {
|
|
111
|
+
"plan-consumed": "Plan consumed — build complete",
|
|
112
|
+
"budget-exhausted": "Reached the build-turn budget; PLAN.md still records remaining work",
|
|
113
|
+
stuck: "Stopped: no forward progress across consecutive turns"
|
|
114
|
+
};
|
|
115
|
+
return ["", reason[result.stop], `Turns ${result.turns}`, `Path ${result.projectDir}`].join("\n");
|
|
116
|
+
}
|
|
94
117
|
function formatSuccess(result) {
|
|
95
118
|
const lines = [
|
|
96
119
|
"",
|
package/dist/constants.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export declare const HARNESS_NAMES: readonly ["opencode", "codex", "claude", "pi
|
|
|
2
2
|
export type HarnessName = (typeof HARNESS_NAMES)[number];
|
|
3
3
|
export declare const STAGES: readonly ["scaffold", "template", "survey-assets", "survey-technology", "goal", "plan", "build", "full"];
|
|
4
4
|
export type Stage = (typeof STAGES)[number];
|
|
5
|
-
export declare const PARALLEL_STAGES: readonly ["
|
|
5
|
+
export declare const PARALLEL_STAGES: readonly ["survey-assets", "survey-technology"];
|
|
6
6
|
export declare const SKILLS: readonly ["drawcall-ai/vitexec", "drawcall-ai/uikitml", "drawcall-ai/acta", "drawcall-ai/market", "drawcall-ai/speech", "drawcall-ai/flipbook", "drawcall-ai/skills"];
|
|
7
7
|
export declare const PACKAGES: readonly ["vitexec@latest", "@drawcall/uikitml@latest", "@drawcall/acta@latest", "@drawcall/market@latest", "@drawcall/flipbook@latest", "@pmndrs/uikit@latest", "@pmndrs/pointer-events@latest", "@pmndrs/viverse@latest", "navcat@^0.4.1", "three@^0.184.0", "vite@^8.0.16", "typescript@^6.0.3", "elics@^3.4.2", "postprocessing@^6.39.1"];
|
|
8
8
|
export declare const PACKAGE_NAMES: readonly ["vitexec", "@drawcall/uikitml", "@drawcall/acta", "@drawcall/market", "@drawcall/flipbook", "@pmndrs/uikit", "@pmndrs/pointer-events", "@pmndrs/viverse", "navcat", "three", "vite", "typescript", "elics"];
|
package/dist/constants.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
export const HARNESS_NAMES = [
|
|
1
|
+
export const HARNESS_NAMES = [
|
|
2
|
+
"opencode",
|
|
3
|
+
"codex",
|
|
4
|
+
"claude",
|
|
5
|
+
"pi",
|
|
6
|
+
"gemini",
|
|
7
|
+
"grok",
|
|
8
|
+
"forge"
|
|
9
|
+
];
|
|
2
10
|
export const STAGES = [
|
|
3
11
|
"scaffold",
|
|
4
12
|
"template",
|
|
@@ -9,10 +17,10 @@ export const STAGES = [
|
|
|
9
17
|
"build",
|
|
10
18
|
"full"
|
|
11
19
|
];
|
|
12
|
-
// In a "full" run these
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
export const PARALLEL_STAGES = ["
|
|
20
|
+
// In a "full" run the template is applied first; then these two surveys run concurrently over the
|
|
21
|
+
// applied state (they write disjoint gitignored scratch files and don't depend on each other) and
|
|
22
|
+
// share a barrier before the goal stage.
|
|
23
|
+
export const PARALLEL_STAGES = ["survey-assets", "survey-technology"];
|
|
16
24
|
export const SKILLS = [
|
|
17
25
|
"drawcall-ai/vitexec",
|
|
18
26
|
"drawcall-ai/uikitml",
|
package/dist/create.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { appendFileSync, existsSync } from "node:fs";
|
|
2
2
|
import { mkdir } from "node:fs/promises";
|
|
3
3
|
import { basename, join, resolve } from "node:path";
|
|
4
4
|
import { CliError, DEFAULT_HARNESS_TIMEOUT_MS, GOAL_FILE, MAX_BUILD_TURNS, PLAN_FILE, SESSION_LOG_FILE } from "./constants.js";
|
|
@@ -20,11 +20,24 @@ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
20
20
|
async function runTurn(harnessRunner, prompt) {
|
|
21
21
|
let result = await runHarnessTurn(harnessRunner, prompt);
|
|
22
22
|
for (let attempt = 2; attempt <= HARNESS_TURN_ATTEMPTS && result.exitCode !== 0 && !result.timedOut; attempt += 1) {
|
|
23
|
-
|
|
23
|
+
const waitMs = RETRY_BACKOFF_MS * (attempt - 1);
|
|
24
|
+
// A retry is otherwise invisible: the runner just logs a second identical "$ <harness> …"
|
|
25
|
+
// invocation. Mark it so the log reads as "turn failed → retrying" rather than as a mysterious
|
|
26
|
+
// duplicate run, and so a slow build turn can be told apart from one that silently re-ran.
|
|
27
|
+
logToSession(harnessRunner.cwd, `[drawcall-create] harness turn failed (exit ${result.exitCode}); retry ${attempt}/${HARNESS_TURN_ATTEMPTS} after ${Math.round(waitMs / 1000)}s`);
|
|
28
|
+
await delay(waitMs);
|
|
24
29
|
result = await runHarnessTurn(harnessRunner, prompt);
|
|
25
30
|
}
|
|
26
31
|
return result;
|
|
27
32
|
}
|
|
33
|
+
function logToSession(cwd, line) {
|
|
34
|
+
try {
|
|
35
|
+
appendFileSync(join(cwd, SESSION_LOG_FILE), `${line}\n`);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// No session log on disk (e.g. a test's caller-supplied runner) — the retry proceeds anyway.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
28
41
|
export async function createProject(prompt, options = {}) {
|
|
29
42
|
const { env = process.env, commandExists = isCommandAvailable } = options;
|
|
30
43
|
const stage = options.stage ?? "full";
|
|
@@ -94,9 +107,10 @@ async function runStages(stage, harnessRunner, progressLog, prompt, projectName,
|
|
|
94
107
|
}
|
|
95
108
|
if (stage === "scaffold")
|
|
96
109
|
return stopped(0);
|
|
97
|
-
// template
|
|
98
|
-
//
|
|
99
|
-
//
|
|
110
|
+
// A "full" run applies the template first, then surveys the applied state (assets + technology)
|
|
111
|
+
// concurrently behind one barrier — the surveys read the implementation the template installed,
|
|
112
|
+
// so they cannot run alongside it. A skip-template full run surveys a bare scaffold instead. A
|
|
113
|
+
// single-stage run does just the requested one (surveys then read whatever is already in the cwd).
|
|
100
114
|
if (stage === "full") {
|
|
101
115
|
const { exitCode } = options.skipTemplate
|
|
102
116
|
? await runSurveyGroup(harnessRunner, progressLog, prompt)
|
|
@@ -169,27 +183,43 @@ async function runGroupTurns(harnessRunner, prompts) {
|
|
|
169
183
|
}
|
|
170
184
|
return Promise.all(prompts.map((prompt) => runTurn(harnessRunner, prompt)));
|
|
171
185
|
}
|
|
172
|
-
/** Apply a fitting Market starter
|
|
186
|
+
/** Apply a fitting Market starter, then survey assets/technology over the applied state. */
|
|
173
187
|
async function runTemplateGroup(harnessRunner, progressLog, prompt) {
|
|
174
|
-
|
|
188
|
+
// The template runs first and commits, so the surveys that follow can read the applied
|
|
189
|
+
// implementation (its src/ and the assets it installed via Market), not race them — the
|
|
190
|
+
// whole point of the template path is to survey what is already here and modify it.
|
|
191
|
+
progressLog.start("template");
|
|
192
|
+
try {
|
|
193
|
+
const template = await runTurn(harnessRunner, buildTemplatePrompt(prompt));
|
|
194
|
+
if (template.exitCode !== 0) {
|
|
195
|
+
progressLog.fail("template");
|
|
196
|
+
return template;
|
|
197
|
+
}
|
|
198
|
+
await finishTemplate(harnessRunner);
|
|
199
|
+
progressLog.succeed("template");
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
progressLog.fail("template");
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
// The two surveys write disjoint scratch files and don't depend on each other, so they run
|
|
206
|
+
// concurrently (serially for opencode — see SERIAL_GROUP_HARNESSES).
|
|
207
|
+
progressLog.start("surveys");
|
|
175
208
|
try {
|
|
176
|
-
// Array order is the call order: each runner pushes synchronously before its first await.
|
|
177
209
|
const results = await runGroupTurns(harnessRunner, [
|
|
178
|
-
buildTemplatePrompt(prompt),
|
|
179
210
|
buildSurveyAssetsPrompt(prompt),
|
|
180
211
|
buildSurveyTechnologyPrompt(prompt)
|
|
181
212
|
]);
|
|
182
213
|
const failed = results.find((result) => result.exitCode !== 0);
|
|
183
214
|
if (failed) {
|
|
184
|
-
progressLog.fail("
|
|
215
|
+
progressLog.fail("surveys");
|
|
185
216
|
return failed;
|
|
186
217
|
}
|
|
187
|
-
|
|
188
|
-
progressLog.succeed("template/surveys");
|
|
218
|
+
progressLog.succeed("surveys");
|
|
189
219
|
return { exitCode: 0 };
|
|
190
220
|
}
|
|
191
221
|
catch (error) {
|
|
192
|
-
progressLog.fail("
|
|
222
|
+
progressLog.fail("surveys");
|
|
193
223
|
throw error;
|
|
194
224
|
}
|
|
195
225
|
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/prompts.js
CHANGED
|
@@ -38,31 +38,37 @@ export function buildSurveyAssetsPrompt(userPrompt) {
|
|
|
38
38
|
|
|
39
39
|
Goal: ${userPrompt}
|
|
40
40
|
|
|
41
|
-
You are surveying, not planning or building:
|
|
41
|
+
You are surveying, not planning or building: map the content (assets) this goal needs against what the project already has and what the Market can still add, so the goal and plan stages can modify what exists efficiently rather than re-acquire content that is already here.
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
Start with what is already installed. A starter may already be applied to this project, and the assets it brought in are the substrate the build modifies — not gaps to fill. Enumerate them with the \`market\` skill's installed-asset listing (read the skill doc for the command; it reads the project's lock file and prints every installed asset's name, version, type, and file path), and skim the project's own files (\`public/\` and the implementation) to see which of those are already wired in and as what. If nothing is installed yet — a bare scaffold — say so plainly; the survey is then purely about what to add.
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
Then survey what the goal still needs that is not already here. Start from the user-visible experience, not from obvious nouns alone: include the things the player sees, hears, controls, collides with, collects, inhabits, and receives as moment-to-moment feedback. For each distinct need, first check whether an already-installed asset covers it; only where none does, search and preview the Drawcall Market for a composable addition (\`npx @drawcall/market\`; the \`market\` skill doc has the commands and the asset types), and judge fit from the real preview — metadata, screenshots, files — not the name. Survey composable building blocks, not another turnkey starter: a second whole-game starter would substitute for building rather than supply a piece, and one may already be applied.
|
|
46
46
|
|
|
47
|
-
Before
|
|
47
|
+
Before writing, cross-check the goal against the Market asset types that could carry it — models, humanoid models and animations, textures, environments, sound effects, background music, and flipbooks. This is a coverage pass, not a checklist to pad the file: mention a type only when it serves this goal or when its absence creates a real gap. Pay special attention to asset needs that are easy to misclassify as "implementation": animation clips, surface materials, sky/HDR, continuous audio such as ambience or background music, one-shot sound effects, and visual feedback such as fire, impacts, magic, weather, UI/world pings, or explosions. When the goal needs both event feedback and ongoing mood, survey both; do not let sound effects stand in for music/ambience, or a visual effect stand in for its matching sound.
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
Before finishing, check that each asset-shaped word or sensory promise in the goal has an entry — resolved as one of three: already installed, a fitting Market asset to add, or a gap where Market has no fitting asset. If the goal names music, a storm, muzzle flashes, impacts, explosions, collectibles, a place type, a character type, or any similar visible/audible thing, the survey should say which of the three it is.
|
|
50
|
+
|
|
51
|
+
Write your findings to ${ASSET_SURVEY_FILE} (scratch, not committed): one entry per user-visible need, marking whether it is already installed (with the asset name and how it is wired), needs a Market addition (with the fitting asset and what it actually is), or is a gap where nothing fits. Keep it concrete and skimmable, e.g.:
|
|
52
|
+
- Player character model: already installed — \`humanoid-tactical-commando@1.0.0\`, wired as the player. Reuse as-is.
|
|
53
|
+
- Enemy zombie model: not installed — \`humanoid-undead-zombie@1.0.0\` fits (a hunched, decayed humanoid); add it.
|
|
54
|
+
- Large outdoor terrain mesh: no fitting Market asset — gap; build from ground textures + scattered props.`;
|
|
52
55
|
}
|
|
53
56
|
export function buildSurveyTechnologyPrompt(userPrompt) {
|
|
54
57
|
return `Stay in the current project directory.
|
|
55
58
|
|
|
56
59
|
Goal: ${userPrompt}
|
|
57
60
|
|
|
58
|
-
You are surveying, not planning or building: catalogue the
|
|
61
|
+
You are surveying, not planning or building: catalogue the capability (technology) situation so the goal and plan stages can modify efficiently — what the project already builds and how, what installed technology serves the goal's remaining needs, and where nothing fits.
|
|
62
|
+
|
|
63
|
+
Read the existing implementation first. A starter may already be applied: read the source it ships (the entry point, \`src/\`, and config) and map what the project already does — the systems it already has (the game loop and architecture, the controller and camera, combat, world generation, UI, audio, and so on) and the technology each is built on. This is what the build will keep or modify, so name it concretely: the system, what it does, and the skill/package/API it uses. If \`src/\` is a bare scaffold with nothing built yet, say so plainly; the survey is then about the toolkit and the goal's needs built from scratch.
|
|
59
64
|
|
|
60
|
-
This project ships
|
|
65
|
+
This project also ships a set of installed agent skills — each a \`SKILL.md\` doc covering one capability — and a set of npm packages, and that set changes over time. Discover what is actually installed now rather than assuming or working from memory: list and read every installed skill's \`SKILL.md\`, and read package.json for the runtime packages. Skim them all first so you know the full toolkit before routing a single need — the skill you skip is the one a need belongs to. Each \`SKILL.md\` is the authority on what its technology does, when it fits, and its limits; for installed library packages, confirm the specific exports against the real types, which can lag the docs, and for technology a skill adds on demand (assets, optional packages) trust the \`SKILL.md\` and note it as an install-time addition. Follow each skill's own routing and limits — when a skill says to compose specific pieces or warns against a shortcut, do that rather than guessing a turnkey that conflicts.
|
|
61
66
|
|
|
62
|
-
Decompose the goal into its concrete needs and map each to the
|
|
67
|
+
Decompose the goal into its concrete needs and map each to where it stands: already built in the implementation (name the system and whether it is reused as-is or modified toward the goal), served by an installed skill/package (the real API/exports that fit), or a fit-gap with no installed technology — often a game-system need like quests, inventory, AI, or a signature custom mechanic — that the build must implement itself. A need the current implementation already satisfies is the cheapest kind; say so rather than re-deriving it, and rather than forcing a poor fit where nothing matches.
|
|
63
68
|
|
|
64
|
-
Write your findings to ${TECH_SURVEY_FILE} (scratch, not committed): one entry per need,
|
|
65
|
-
- <need>:
|
|
69
|
+
Write your findings to ${TECH_SURVEY_FILE} (scratch, not committed): one entry per need, marking it already-built (the system and the tech it uses, reused or modified), installed-tech-fits (the skill/package and the real API/exports), or fit-gap (the build implements it). Keep it concrete and skimmable, e.g. one bullet per need:
|
|
70
|
+
- <need>: already built — <system / file role> on <tech> — reuse as-is, or modify <how> toward the goal.
|
|
71
|
+
- <need>: <fitting skill/package> — <the real API/exports that matter> — following the skill's recommended composition.
|
|
66
72
|
- <need>: no installed technology fits — fit-gap, the build implements it.`;
|
|
67
73
|
}
|
|
68
74
|
export function buildGoalPrompt(userPrompt) {
|
|
@@ -73,7 +79,7 @@ Goal: ${userPrompt}
|
|
|
73
79
|
${buildPrinciples()}
|
|
74
80
|
|
|
75
81
|
Create ${GOAL_FILE}: a concrete, fixed picture of the finished game that every later turn builds toward. It is the target, not a plan or task list, and stays stable as the project grows.
|
|
76
|
-
Ground it in what actually exists: read the surveys ${ASSET_SURVEY_FILE} and ${TECH_SURVEY_FILE} for the assets and technology that fit, and read ${README_FILE} for the current state — weighing how close that state already is to what the request wants. When ${README_FILE} shows a substantial, coherent game already in the same space as the request (an applied starter, not a bare scaffold), anchor the finished picture on that real implementation: take the scope and shape it has already settled as the substrate, and reach past it only for what the request genuinely needs and the current state does not yet deliver — rather than re-deriving an idealized version that re-opens settled scope or adds large systems the starter deliberately leaves out. The closer the current state already is to realizing the request, the more the goal is bound to it; a bare scaffold or a poor-fit starter binds it not at all, and the goal is then the full target the request deserves. Name the handful that define the look, feel, and mechanics (e.g. "low-poly style, terrain from asset A, props B/C, movement via skill D") — the defining choices, not a full asset manifest (the surveys already hold that). Where nothing fits a needed part, say so as a fit-gap rather than quietly dropping it.
|
|
82
|
+
Ground it in what actually exists: read the surveys ${ASSET_SURVEY_FILE} and ${TECH_SURVEY_FILE} for the assets and technology that fit, and read ${README_FILE} for the current state — weighing how close that state already is to what the request wants. When ${README_FILE} shows a substantial, coherent game already in the same space as the request (an applied starter, not a bare scaffold), anchor the finished picture on that real implementation: take the scope and shape it has already settled as the substrate, and reach past it only for what the request genuinely needs and the current state does not yet deliver — rather than re-deriving an idealized version that re-opens settled scope or adds large systems the starter deliberately leaves out. The closer the current state already is to realizing the request, the more the goal is bound to it; a bare scaffold or a poor-fit starter binds it not at all, and the goal is then the full target the request deserves. Binding to the substrate means reusing what works, not holding back on what is cheap to change and decisive to the player. A template becomes the request's own game only when it stops reading as the recognizable starter and reads as the requested game: the working systems and the settled scope stay, but the surface the player meets is re-shaped to the request — above all the game's own name and identity, and the signature screens and UI language that brand the original (a title card, an end-screen verdict, the framing copy a player would recognize). These changes are inexpensive and decisive, so the goal commits to a distinct identity for this game and treats re-shaping that recognizable surface as part of what the request needs, not optional polish. Reason from this specific request about which cheap, high-leverage changes carry the most of its feeling — do not work down a fixed list, and do not stop at swapping one system while the game still announces itself as the starter. Name the handful that define the look, feel, and mechanics (e.g. "low-poly style, terrain from asset A, props B/C, movement via skill D") — the defining choices, not a full asset manifest (the surveys already hold that). Where nothing fits a needed part, say so as a fit-gap rather than quietly dropping it.
|
|
77
83
|
|
|
78
84
|
Write it the way a strong, short game design document reads — concrete, decisive, easy to picture — but as a layered design, not a flat list of headings. A game's design runs from the experience it is for at the top down to how each thing looks and feels in the player's hands at the bottom, and the levels are causal in both directions: the top decides why every lower thing exists, while the player meets the game from the bottom up — touching the feel of a single action first, and only through it sensing the experience you aimed at. So design top-down and keep each lower choice traceable to the level above (this enemy, this loop, this verb earns its place by serving the experience), and never stop at rules-on-paper — because the player lives at the bottom, the bottom must be drawn as concretely as the top.
|
|
79
85
|
The levels below, with the questions that live at each, are context to reason from, not a template to fill or a checklist to tick. Reason from this specific game about which levels carry it and how deep each goes — a puzzler lives on its mechanics, an exploration game on its world and mood, an arcade game on feel and mastery, a story RPG on its characters and the plot and world they inhabit — and say plainly what you are deliberately keeping thin or absent, which is itself a design decision. Go as deep on the level a game lives on as that game needs: the depth a story game owes its characters and plot is the depth an action game owes its feel — do not let any one level default to thin because it is harder to write. Add a question a level needs that is not here.
|
|
@@ -104,6 +110,8 @@ Write ${PLAN_FILE}: the ordered steps from the current state to the goal. A step
|
|
|
104
110
|
|
|
105
111
|
Size each step to the most a single build-turn can confidently build and stand behind in one go, and group into it the features that complete one testable capability together. Resolve the real tension between two failure modes: a pure one-mechanic-at-a-time slice proves out every turn but tends to build throwaway scaffolding (a stand-in you later discard, a thin version you rewrite) and pay for the same area twice; a pure feature-batch implements each feature once but is too big to build and prove in a turn. Aim for the middle — group the features that naturally belong to one capability so each is implemented once, against its real collaborators, while the step still fits and proves in one turn. A system that only proves out with another (shooting needs something to shoot, loot needs someone to drop it) is usually a cue to group the two into one step, not to split them behind a stand-in: prefer building a feature with its real collaborators when they fit the same turn. Reach for a deliberate stand-in only when the real collaborator genuinely cannot fit the same turn and the stand-in is cheap and minimal — never substantial scaffolding you will throw away. Build shared foundations right the first time: the ECS spine, the controller/camera rig, the audio system and other substrate should be established correctly when first needed and reused — not built thin and "consolidated" in a later refactor (a planned pure-refactor step is a sign a foundation was under-built, and a build-turn spent on rework is a step the budget can't afford). A step that cannot exist until an earlier one lands comes after it. Size each step to one build-turn's worth, and let the plan run exactly as long as the real distance from the current state to the goal demands — no longer. The build-turn budget is a ceiling, not a quota to fill: when the current state already realizes most of the goal the plan is correspondingly short — as few as a single step — and you neither pad it with generic polish or re-verification of what already works nor stretch a small delta to resemble a full build; when the distance is large, use as many right-sized steps as it takes, preferring a few whole steps to a long trail of fragments and never a step too large for one turn to finish and stand behind.
|
|
106
112
|
|
|
113
|
+
When the plan starts from an applied starter rather than a blank repo, most steps are modifications of what already runs, not new construction: reskinning the look, renaming and re-theming the cast, swapping a model or texture, re-grading the lighting, re-parameterizing the procedural generation, re-pointing a system's inputs or win condition. Favor the cheapest change that lands the goal's intended shift in how the game reads and feels — a deep change in the experience need not be a deep change in the code, and re-shaping what exists usually beats rebuilding it. Plan the cheap, high-impact re-shapings of the player-facing surface that the goal commits to — the game's name and identity, the signature screens, the framing copy that brands the original — as first-class step content, not afterthoughts: each is a small edit, but together they are the highest-leverage work in a reskin, the easiest to drop under pressure, and what stops the result reading as the recognizable starter; leaving them undone leaves the player facing the old game's identity. Size and gate such a step exactly like a built-from-scratch slice — outcome, fit, and a gate that confirms it reads right when run — and do not add a step for any part of the goal the current state already satisfies.
|
|
114
|
+
|
|
107
115
|
A slice is the whole of the thing it introduces, not its mechanic alone. Reason from the goal about what makes each one real in the player's hands — its animation and feedback, how the player comes to know what to do and can see their current goal, the transitions and screens that frame it — and carry those into the same step, never deferring them to a later polish the embodiment principle forbids. Which of these a slice lives on depends on what it is; weigh them, do not tick them off a list.
|
|
108
116
|
|
|
109
117
|
Some of the game is not a vertical slice at all — the continuous, cross-cutting layers no single feature owns: the audio bed (ambience and music), the atmospheric look (lighting mood and the postprocessing pass), the shared HUD frame, and the first-load/loading screen. Place these deliberately instead of letting them fall to a final step a stalled plan may never reach. Introduce a layer's foundation in the first step where the player would feel its absence — world ambience and music with the first explorable world, the look pass once there is a scene to grade, the loading screen in the very first step (any real build loads assets before it can show anything, so a blank canvas is felt from the start) — and extend it as the game grows. Foundational mood is part of the feel the goal commits to, not last-minute polish; a game that reaches its last planned step before it has any music or atmosphere was planned in the wrong order.
|
|
@@ -132,7 +140,8 @@ ${buildSliceMethod()}
|
|
|
132
140
|
Use ${README_FILE} as the claimed current state, ${GOAL_FILE} as the fixed goal, and ${PLAN_FILE} as the plan.
|
|
133
141
|
Take the first remaining ${PLAN_FILE} step as this turn's task and build the whole of it — the grouped features it names — with the fitting skills/packages/assets, allowing only the small prerequisites or repairs that make the step actually work. A right-sized step is one turn's work, so complete it rather than fragmenting it.
|
|
134
142
|
Only if the step genuinely cannot fit one turn, split off the smallest coherent remainder as a single new ${PLAN_FILE} step (not a trail of fragments), and finish the rest now. Do not add pure-refactor, cleanup, or "consolidate the architecture" steps that don't advance the product — build the foundation correctly here, which means laying the code out as cohesive modules from the first turn (follow each skill's recommended file layout, such as the ecs skill's one-file-per-component and one-file-per-system split, rather than piling the game into one growing main file) instead of leaving rework for a future turn the budget can't afford. When a feature needs a collaborator that exists later in the plan, prefer pulling it forward into this step over building a throwaway stand-in you will discard.
|
|
135
|
-
Prove the result with a proof-run that actually launches and drives the real running repo this turn — a written description is never a substitute for a run. The proof is a machine-produced artifact saved under \`${PROOF_DIR}/\` (gitignored scratch): a screenshot or clip captured from the running app, or — if you cannot view images — a recorded runtime-state dump that asserts the real-done runtime facts, each produced by the command you actually ran rather than authored by hand. Look at the screenshots/clips from the player's seat and judge them against the goal's real-done bar, iterating on the build until it reads right; a prose "verification" note with no run behind it does not satisfy the gate.
|
|
143
|
+
Prove the result with a proof-run that actually launches and drives the real running repo this turn — a written description is never a substitute for a run. The proof is a machine-produced artifact saved under \`${PROOF_DIR}/\` (gitignored scratch): a screenshot or clip captured from the running app, or — if you cannot view images — a recorded runtime-state dump that asserts the real-done runtime facts, each produced by the command you actually ran rather than authored by hand. Look at the screenshots/clips from the player's seat and judge them against the goal's real-done bar, iterating on the build until it reads right; a prose "verification" note with no run behind it does not satisfy the gate. When the step modifies an already-proven product, scope the proof to what this step actually changed — the new behavior, the new look, the new feedback — and rely on the carried proof for systems reused unchanged rather than re-driving and re-proving the whole game each turn; that re-verification is the budget the turn cannot afford. Spend the time it saves on completing the step's full named scope: the cheap, high-impact edits — the identity, the wordmark, the title and HUD copy, the palette — whose deep effect on how the product reads is easy to drop under time pressure are exactly the ones that make it read as its new self, so land them, never leave the player-facing name or framing describing the old product. Then update ${PLAN_FILE} to reflect what is actually proven: close the step you finished, and if a turn only proved part of it, keep the unproven parts as first-class remaining steps rather than caveats.
|
|
144
|
+
When the last step is proven and the goal is genuinely realized in the running product — its playthroughs reproduce end to end and every real-done aspect is met — delete ${PLAN_FILE} so the pipeline knows the build is complete and stops; this is the done signal, so do not spend a further turn re-verifying what is already proven. While any real work remains, keep ${PLAN_FILE} holding only the steps that are still open.
|
|
136
145
|
If real findings change the project understanding, update ${GOAL_FILE} lightly and honestly.
|
|
137
146
|
Finally, update ${README_FILE} so it truthfully describes the new proven state, what changed, what remains in ${PLAN_FILE}, and any remaining gaps.`;
|
|
138
147
|
}
|
package/dist/scaffold.js
CHANGED
|
@@ -12,7 +12,24 @@ export async function initGitRepo(cwd, runner) {
|
|
|
12
12
|
}
|
|
13
13
|
export async function commitAll(cwd, runner, message) {
|
|
14
14
|
await assertExitCode(runner({ command: "git", args: ["add", "-A"], cwd }), "failed to stage changes");
|
|
15
|
-
|
|
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
|
|
31
|
+
});
|
|
32
|
+
return exitCode === 0;
|
|
16
33
|
}
|
|
17
34
|
export async function initNpmProject(cwd, _runner) {
|
|
18
35
|
const packageJsonPath = join(cwd, "package.json");
|
package/dist/subprocess.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createWriteStream } from "node:fs";
|
|
3
3
|
import { delimiter, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
4
5
|
import which from "which";
|
|
5
6
|
import { CliError, TIMEOUT_EXIT_CODE, TIMEOUT_KILL_GRACE_MS } from "./constants.js";
|
|
7
|
+
import { formatDuration } from "./progress-log.js";
|
|
6
8
|
export function createSubprocessRunner(options = { stdio: "inherit" }) {
|
|
7
9
|
return (invocation) => runSubprocess(invocation, options);
|
|
8
10
|
}
|
|
9
11
|
function runSubprocess({ command, args, cwd, timeoutMs }, options) {
|
|
10
12
|
return new Promise((resolveResult, reject) => {
|
|
13
|
+
const startedAt = Date.now();
|
|
11
14
|
// A separate process group lets a timeout kill the whole child tree at once.
|
|
12
15
|
const detached = process.platform !== "win32";
|
|
13
16
|
// When a log file is set, capture all child output into it and keep the
|
|
@@ -20,8 +23,14 @@ function runSubprocess({ command, args, cwd, timeoutMs }, options) {
|
|
|
20
23
|
stdio: logStream ? ["ignore", "pipe", "pipe"] : (options.stdio ?? "inherit")
|
|
21
24
|
});
|
|
22
25
|
if (logStream) {
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
// Stamp every captured line with the wall-clock time we received it instead of piping raw
|
|
27
|
+
// bytes. Reading the harness's output at our own boundary and timing it with our own clock is
|
|
28
|
+
// the one timing signal robust across every harness and version — it depends on no private
|
|
29
|
+
// session-transcript format or structured-output schema (both drift, and for backgrounded
|
|
30
|
+
// tool calls misreport durations). The gaps between timestamps are then where the wall-clock
|
|
31
|
+
// went: a long stall with no output is the model thinking or one command grinding.
|
|
32
|
+
forwardWithTimestamps(child.stdout, logStream);
|
|
33
|
+
forwardWithTimestamps(child.stderr, logStream);
|
|
25
34
|
}
|
|
26
35
|
let timedOut = false;
|
|
27
36
|
let killTimer;
|
|
@@ -60,10 +69,21 @@ function runSubprocess({ command, args, cwd, timeoutMs }, options) {
|
|
|
60
69
|
reject(error);
|
|
61
70
|
});
|
|
62
71
|
child.once("exit", (code, signal) => {
|
|
72
|
+
const exitCode = timedOut ? TIMEOUT_EXIT_CODE : signal ? 1 : (code ?? 1);
|
|
73
|
+
// Always record how long the command ran, so the log shows where wall-clock goes (each
|
|
74
|
+
// scaffold step, and each harness turn's total) without digging into the harness's own
|
|
75
|
+
// session transcript. A non-zero exit is named on the same line so a failure is unmistakable
|
|
76
|
+
// and the boundary between a failed turn and a following retry stays legible. Timeouts
|
|
77
|
+
// already logged their own notice, so only the duration is added for them.
|
|
78
|
+
if (logStream) {
|
|
79
|
+
const elapsed = formatDuration(Date.now() - startedAt);
|
|
80
|
+
const failure = !timedOut && exitCode !== 0
|
|
81
|
+
? ` — exit ${exitCode}${signal ? ` (signal ${signal})` : ""}`
|
|
82
|
+
: "";
|
|
83
|
+
logStream.write(`[drawcall-create] ${command} done in ${elapsed}${failure}\n`);
|
|
84
|
+
}
|
|
63
85
|
cleanup();
|
|
64
|
-
|
|
65
|
-
return resolveResult({ exitCode: TIMEOUT_EXIT_CODE, timedOut: true });
|
|
66
|
-
resolveResult({ exitCode: signal ? 1 : (code ?? 1), timedOut: false });
|
|
86
|
+
resolveResult({ exitCode, timedOut });
|
|
67
87
|
});
|
|
68
88
|
});
|
|
69
89
|
}
|
|
@@ -100,6 +120,23 @@ function openSessionLog(logFile, command) {
|
|
|
100
120
|
stream.write(`\n$ ${command.join(" ")}\n`);
|
|
101
121
|
return stream;
|
|
102
122
|
}
|
|
123
|
+
// Copy a child stream into the log a line at a time, stamping each line with the wall-clock time we
|
|
124
|
+
// received it. readline does the line splitting (including a final line with no trailing newline,
|
|
125
|
+
// and \r\n) so a timestamp only ever prefixes a whole line.
|
|
126
|
+
function forwardWithTimestamps(source, sink) {
|
|
127
|
+
if (!source)
|
|
128
|
+
return;
|
|
129
|
+
const lines = createInterface({ input: source });
|
|
130
|
+
lines.on("line", (line) => sink.write(`${logTimestamp()} ${line}\n`));
|
|
131
|
+
}
|
|
132
|
+
// Absolute wall-clock HH:MM:SS.mmm so any two lines anywhere in the log — within a turn or across
|
|
133
|
+
// stages — can be subtracted directly, and the gap from the last output line to the "done in"
|
|
134
|
+
// footer (the idle tail before the process exits) is visible too.
|
|
135
|
+
function logTimestamp() {
|
|
136
|
+
const now = new Date();
|
|
137
|
+
const pad = (value, length = 2) => String(value).padStart(length, "0");
|
|
138
|
+
return `[${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${pad(now.getMilliseconds(), 3)}]`;
|
|
139
|
+
}
|
|
103
140
|
function killChildProcess(pid, detached, signal) {
|
|
104
141
|
if (!pid)
|
|
105
142
|
return;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
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>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
3
|
+
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;
|
|
57
|
+
}
|
|
58
|
+
function captureProgressMarker(cwd, planPath) {
|
|
59
|
+
return {
|
|
60
|
+
head: headCommit(cwd),
|
|
61
|
+
planText: existsSync(planPath) ? readFileSync(planPath, "utf8") : ""
|
|
62
|
+
};
|
|
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();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return "";
|
|
72
|
+
}
|
|
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`);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// No session log on disk (e.g. a test working dir) — the supervisor proceeds anyway.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
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));
|
|
100
|
+
}
|
|
101
|
+
args.push(BUILD_RESUME_PROMPT);
|
|
102
|
+
if (options.harnessArgs && options.harnessArgs.length > 0) {
|
|
103
|
+
args.push("--", ...options.harnessArgs);
|
|
104
|
+
}
|
|
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";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drawcall/create",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Create projects with an installed local harness.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"node": ">=20"
|
|
13
13
|
},
|
|
14
14
|
"bin": {
|
|
15
|
-
"drawcall-create": "
|
|
15
|
+
"drawcall-create": "dist/cli.js"
|
|
16
16
|
},
|
|
17
17
|
"exports": {
|
|
18
18
|
".": {
|
|
@@ -24,6 +24,15 @@
|
|
|
24
24
|
"files": [
|
|
25
25
|
"dist"
|
|
26
26
|
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"dev": "tsx src/cli.ts",
|
|
29
|
+
"build": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"",
|
|
30
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"format": "prettier --write .",
|
|
33
|
+
"format:check": "prettier --check .",
|
|
34
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
35
|
+
},
|
|
27
36
|
"dependencies": {
|
|
28
37
|
"commander": "^14.0.3",
|
|
29
38
|
"which": "^6.0.1"
|
|
@@ -35,13 +44,5 @@
|
|
|
35
44
|
"tsx": "^4.19.2",
|
|
36
45
|
"typescript": "^5.7.2",
|
|
37
46
|
"vitest": "^4.1.8"
|
|
38
|
-
},
|
|
39
|
-
"scripts": {
|
|
40
|
-
"dev": "tsx src/cli.ts",
|
|
41
|
-
"build": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"",
|
|
42
|
-
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
|
|
43
|
-
"test": "vitest run",
|
|
44
|
-
"format": "prettier --write .",
|
|
45
|
-
"format:check": "prettier --check ."
|
|
46
47
|
}
|
|
47
|
-
}
|
|
48
|
+
}
|