Implement Codex game studio functionality gap pass

This commit is contained in:
MerlinH
2026-05-28 15:11:30 +00:00
parent f8530077d9
commit dabf1183c3
53 changed files with 4385 additions and 813 deletions
+1 -1
View File
@@ -12,4 +12,4 @@ Do not load all agents or all templates for a single role task.
`src/agents.ts` is the single owner for generated project `AGENTS.md`.
Direct Codex execution is a first-class path via `open-gamestudio run <agent> --exec`. Telemetry, planner/next, ownership enforcement, and parallel orchestration are future-only.
Direct Codex execution is the default path via `open-gamestudio run <role>`. `--dry-run` and `--print-prompt` are inspection-only paths. Telemetry, planner/next, ownership enforcement, and parallel orchestration are future-only.
+23 -11
View File
@@ -1,8 +1,8 @@
# Open GameStudio
Open GameStudio is a Node/TypeScript CLI package for creating and managing local, agent-assisted game projects. It provides project scaffolding, engine-aware configuration, base agent prompts, reusable templates, bounded prompt packets, and validation gates that keep generated project artifacts predictable.
Codex Game Studio is a Node/TypeScript CLI package for creating and managing local, Codex-assisted game projects. It provides project scaffolding, engine-aware Codex context, role prompts, reusable templates, file-backed tasks, and validation gates that keep generated project artifacts predictable.
The package is a Codex-first agent workflow layer for game making. It keeps project state local and inspectable while integrating directly with `codex exec` for role-specific work. The CLI prepares bounded prompt packets, invokes Codex when requested, and preserves deterministic validation gates around generated artifacts.
The package is a Codex-native workflow layer for game making. It keeps project state local and inspectable under `.codex/`, invokes `codex exec` by default for role-specific work, and uses `--dry-run` or `--print-prompt` for non-executing inspection.
## Why This Exists
@@ -15,7 +15,7 @@ Claude Game Studio deserves real kudos for proving that role-based game-developm
- Generated game projects live under `projects/<slug>/`.
- Engine configs, templates, and base agents are package assets.
- Validation is explicit and hard-failing instead of advisory.
- Prompt packets are optimized for Codex and can be executed directly with `--exec`.
- Prompt packets are optimized for Codex and execute by default through `run <role>`.
- Telemetry, planner/`next`, ownership enforcement, changed-file tracking, and parallel orchestration are future-only.
The goal is not to clone another tool. The goal is to make the workflow contract inspectable, portable, and easy to run in normal developer tooling.
@@ -24,6 +24,7 @@ The goal is not to clone another tool. The goal is to make the workflow contract
- Node.js 20 or newer.
- npm.
- Codex CLI for normal execution and repository validation.
## Install
@@ -80,16 +81,16 @@ Run a project agent through Codex:
```sh
npm run build --silent
node dist/cli.js run market_analyst --project projects/my-game --task "Create the initial market overview." --exec
node dist/cli.js run producer --project projects/my-game "Create the initial market overview."
```
Inspect the generated prompt packet without executing Codex:
```sh
node dist/cli.js run market_analyst --project projects/my-game --task "Create the initial market overview." --dry-run
node dist/cli.js run producer --project projects/my-game "Create the initial market overview." --dry-run
```
The `run` command writes a bounded prompt packet and, with `--exec`, immediately invokes `codex exec` in the project root. Use `--dry-run` or `--print-prompt` when you want to inspect the exact Codex context first.
The `run` command writes a bounded prompt packet and immediately invokes `codex exec` in the project root. Use `--dry-run` or `--print-prompt` when you want to inspect the exact Codex context first.
## CLI Commands
@@ -100,14 +101,23 @@ The `run` command writes a bounded prompt packet and, with `--exec`, immediately
- `validate`: run repository or project validation and exit nonzero on failure.
- `templates list`: list packaged template IDs.
- `templates show <template-id>`: print a packaged template.
- `run <agent>`: prepare one bounded Codex prompt packet for a project agent; add `--exec` to invoke `codex exec` immediately.
- `run <role>`: prepare one bounded Codex prompt packet for a studio role and invoke `codex exec` by default.
- `task create` / `task run`: manage file-backed `.codex/tasks.json` tasks.
- `review`, `ship-check`: render existing baseline Codex workflow prompts.
- `market`, `analytics`, `design-spec`, `feel-review`, `art-direction`, `ui-review`, `milestone`, `handoff`: render workflow prompts only; these shortcuts do not launch Codex.
## Studio Roles
The Codex-native role roster is `studio-orchestrator`, `producer`, `market-analyst`, `data-scientist`, `creative-director`, `senior-game-designer`, `game-designer`, `narrative-designer`, `game-feel-designer`, `gameplay-programmer`, `engine-programmer`, `tools-programmer`, `senior-game-artist`, `technical-artist`, `ui-ux-designer`, `qa-playtester`, and `release-manager`.
This preserves Claude Game Studio functional coverage without legacy underscore role IDs. `narrative-designer` remains a first-class Codex-native story/content owner.
## Project Layout
Repository assets:
- `src/`: TypeScript CLI implementation.
- `agents/base/`: base role prompts packaged with the CLI.
- `src/roles.ts`: Codex role packages compiled into the CLI.
- `templates/`: reusable document and setup templates.
- `engine_configs/`: engine overlays for Godot, Unity, and Unreal.
- `docs/`: setup, migration, validation, and example notes.
@@ -116,11 +126,13 @@ Repository assets:
Generated project artifacts:
- `projects/<slug>/`: the project root created by `init`.
- `project.gamestudio.json`: project metadata and workflow state.
- `AGENTS.md`: generated project instructions owned by `src/agents.ts`.
- `AGENTS.md`: primary generated Codex project instructions, owned by `src/agents.ts`.
- `.codex/studio.json`: authoritative project metadata and workflow state.
- `.codex/prompts/`: generated role prompts.
- `.codex/workflows/`: generated workflow prompts.
- `.codex/runs/`: prepared prompt packets and run metadata.
- `documentation/`: generated game-design and workflow documents.
- `source/project-<slug>/`: engine project location contract.
- `.gamestudio/runs/`: prepared prompt packets and run metadata.
## Development
-27
View File
@@ -1,27 +0,0 @@
# Role
Define analytics, metrics, events, and experiment-readiness for the project.
# Inputs
Project config, core loop, target audience, and analytics template.
# Outputs
Analytics plan, event taxonomy, metric definitions, and validation notes.
# Output Paths
Use `documentation/technical/analytics/analytics-plan.md`.
# Validation
Run `npm run validate -- --project <project>`.
# Engine Notes
Map instrumentation ideas to the selected engine.
# Rules
Do not add telemetry for this toolkit; discuss only game analytics artifacts.
-27
View File
@@ -1,27 +0,0 @@
# Role
Improve responsiveness, feedback, controls, camera, and tuning.
# Inputs
Project config, mechanics notes, engine overlay, and playtest observations.
# Outputs
Game-feel tuning notes, implementation tasks, and validation criteria.
# Output Paths
Use `documentation/design/feel/` and selected engine source paths.
# Validation
Run `npm run validate -- --project <project>`.
# Engine Notes
Map feedback and tuning work to engine-specific systems.
# Rules
Keep changes measurable and playtest-oriented.
-27
View File
@@ -1,27 +0,0 @@
# Role
Analyze audience, positioning, competitors, and monetization fit.
# Inputs
Project config, competitor names, audience, genre, platform, and market template.
# Outputs
Market overview, competitor comparison, positioning risks, and recommended research questions.
# Output Paths
Use `resources/market-research/market-analysis.md`.
# Validation
Run `npm run validate -- --project <project>`.
# Engine Notes
Relate market risks to engine/platform constraints.
# Rules
Do not generate eager per-competitor reports unless the task asks for them.
-27
View File
@@ -1,27 +0,0 @@
# Role
Coordinate the game-studio workflow, sequence work, and keep scope aligned with project goals.
# Inputs
Project config, current task, selected engine notes, and relevant handoff material.
# Outputs
Coordination notes, next role recommendation, and explicit artifact paths.
# Output Paths
Use `documentation/handoffs/` for handoffs and reference project artifacts instead of embedding them.
# Validation
Run `npm run validate -- --project <project>`.
# Engine Notes
Adapt sequencing to the selected engine overlay.
# Rules
Use bounded context. Do not run Codex, telemetry, planner, parallel orchestration, or ownership enforcement.
-27
View File
@@ -1,27 +0,0 @@
# Role
Design and implement gameplay mechanics plans within the selected engine contract.
# Inputs
Project config, engine setup notes, feature spec, and current task.
# Outputs
Mechanics implementation notes, source-path guidance, and validation checks.
# Output Paths
Use `source/project-<slug>/` and `documentation/technical/`.
# Validation
Run `npm run validate -- --project <project>`.
# Engine Notes
Use the selected engine overlay for folder and project-file expectations.
# Rules
Respect the `source/project-<slug>/` contract.
-27
View File
@@ -1,27 +0,0 @@
# Role
Elaborate features, levels, content, and moment-to-moment design details.
# Inputs
Project config, senior design direction, feature template, and current task.
# Outputs
Feature notes, content lists, and playtest-ready acceptance criteria.
# Output Paths
Use `documentation/design/features/`.
# Validation
Run `npm run validate -- --project <project>`.
# Engine Notes
Keep implementation detail compatible with the selected engine.
# Rules
Do not load unrelated templates unless the task requires them.
-27
View File
@@ -1,27 +0,0 @@
# Role
Own production planning, milestones, status summaries, and project delivery rhythm.
# Inputs
Project config, milestones, timeline, current task, and validation state.
# Outputs
Production plan updates, risks, and clear next validation gates.
# Output Paths
Use `documentation/production/` and update config only when explicitly requested.
# Validation
Run `npm run validate -- --project <project>`.
# Engine Notes
Account for engine-specific setup and build risks.
# Rules
Keep operational status separate from generated guidance hashes.
-27
View File
@@ -1,27 +0,0 @@
# Role
Review validation readiness, test plans, acceptance criteria, and regressions.
# Inputs
Project config, task details, validation command, and selected artifacts.
# Outputs
QA plan, failure risks, reproduction steps, and validation checklist.
# Output Paths
Use `documentation/qa/`.
# Validation
Run `npm run validate -- --project <project>`.
# Engine Notes
Check engine-specific project-file and source-root requirements.
# Rules
Do not run agents or broad orchestration; report manual next commands.
-27
View File
@@ -1,27 +0,0 @@
# Role
Own art direction, visual targets, asset priorities, and style consistency.
# Inputs
Project config, audience, design goals, engine overlay, and art task.
# Outputs
Art direction notes, asset lists, and production-ready briefs.
# Output Paths
Use `documentation/art/` and engine asset folders under `source/project-<slug>/`.
# Validation
Run `npm run validate -- --project <project>`.
# Engine Notes
Consider import paths and asset conventions for the selected engine.
# Rules
Do not create unrelated assets without explicit task scope.
-27
View File
@@ -1,27 +0,0 @@
# Role
Own senior design direction, core loop quality, systems fit, and feature specs.
# Inputs
Project config, GDD, feature request, engine notes, and design template.
# Outputs
Design decisions, GDD updates, feature specs, and acceptance criteria.
# Output Paths
Use `documentation/design/gdd.md` and `documentation/design/features/`.
# Validation
Run `npm run validate -- --project <project>`.
# Engine Notes
Adapt designs to selected engine affordances.
# Rules
Prefer scoped design artifacts over broad rewrites.
-27
View File
@@ -1,27 +0,0 @@
# Role
Bridge art and engineering for shaders, pipelines, import settings, and performance.
# Inputs
Project config, art direction, engine overlay, and technical constraints.
# Outputs
Pipeline notes, technical art tasks, and validation checks.
# Output Paths
Use `documentation/art/technical/` and engine source asset paths.
# Validation
Run `npm run validate -- --project <project>`.
# Engine Notes
Use engine-specific material, shader, and import conventions.
# Rules
Keep pipeline guidance reproducible.
-27
View File
@@ -1,27 +0,0 @@
# Role
Design UI flows, HUDs, menus, accessibility notes, and interaction ergonomics.
# Inputs
Project config, audience, platform, engine overlay, and UI task.
# Outputs
UI specs, screen flows, HUD requirements, and validation criteria.
# Output Paths
Use `documentation/design/ui-ux/`.
# Validation
Run `npm run validate -- --project <project>`.
# Engine Notes
Map UI recommendations to selected engine UI systems.
# Rules
Protect gameplay readability and avoid broad unrelated context.
+1 -1
View File
@@ -15,4 +15,4 @@ npm run validate
Keep generated projects under `projects/<slug>/`.
The first build includes direct Codex execution through `run --exec`. It still intentionally excludes planner commands, telemetry, parallel orchestration, changed-file tracking, and ownership enforcement.
The current build invokes Codex by default through `run <role>`. Use `--dry-run` or `--print-prompt` for inspection-only paths. It still intentionally excludes planner commands, telemetry, parallel orchestration, changed-file tracking, and ownership enforcement.
+22 -15
View File
@@ -1,28 +1,35 @@
# Examples
Create and validate a project:
Create and validate a project from this repository:
```bash
npm exec open-gamestudio -- init --name "My Game" --engine godot --mode prototype --non-interactive --competitor "Mini Metro" --competitor "Dorfromantik"
npm exec open-gamestudio -- status --project projects/my-game
npm exec open-gamestudio -- validate --project projects/my-game
```sh
npm run build
npm run init -- --name "Rogue Core" --engine godot --mode prototype --non-interactive --competitor "Mini Metro" --competitor "Dorfromantik"
npm run manage -- --project projects/rogue-core
npm run validate -- --project projects/rogue-core
```
Run a role agent directly through Codex:
Run a studio role directly through Codex:
```bash
npm exec open-gamestudio -- run market_analyst --project projects/my-game --task "Create the initial market overview." --exec
```sh
npm run build && node dist/cli.js run producer --project projects/rogue-core "Create the initial market overview."
```
Inspect the bounded Codex prompt packet first:
Inspect prompts without launching Codex:
```bash
npm exec open-gamestudio -- run market_analyst --project projects/my-game --task "Create the initial market overview." --dry-run
```sh
npm run build && node dist/cli.js run producer --project projects/rogue-core "Create the initial market overview." --dry-run
npm run build && node dist/cli.js market --project projects/rogue-core --dry-run
npm run build && node dist/cli.js analytics --project projects/rogue-core --dry-run
npm run build && node dist/cli.js handoff --project projects/rogue-core --dry-run
npm run build && node dist/cli.js design-spec --project projects/rogue-core --dry-run
npm run build && node dist/cli.js feel-review --project projects/rogue-core --dry-run
npm run build && node dist/cli.js ui-review --project projects/rogue-core --dry-run
```
Discover templates:
Discover packaged templates:
```bash
npm exec open-gamestudio -- templates list
npm exec open-gamestudio -- templates show market_analysis
```sh
npm run templates -- list
npm run templates -- show market_analysis
```
+11 -1
View File
@@ -10,8 +10,18 @@ Legacy Unreal naming used multiple labels. This port normalizes `Unreal`, `Unrea
Legacy validation depended on Python and shell assumptions. This port is TypeScript/Node only.
Role roster coverage is preserved through Codex-native IDs: `studio-orchestrator`, `market-analyst`, `data-scientist`, senior design/art roles, game-feel, UI/UX, QA, release, and implementation roles. Legacy underscore aliases such as `producer_agent`, `qa_agent`, and `master_orchestrator` are intentionally not valid role IDs.
Generated projects materialize project-specific `.codex/prompts/<role>.md` files for every role. `AGENTS.md` remains the primary generated Codex instruction surface and is owned by `src/agents.ts`.
Market and analytics are first-class renderable workflows owned by dedicated roles. Their prompts inline the selected package template bodies instead of pointing Codex at project-relative template paths.
Studio orchestration is provided by the `studio-orchestrator` role and the render-only `handoff` workflow shortcut, not by a generated `project_orchestrator.md`.
Richer workflows exist for design specs, game-feel review, art direction, UI/UX review, production milestones, review, ship-check, playtest, bugfix, vertical slice, market, analytics, and handoff. Shortcut workflow commands render prompts only; executable workflow lifecycle support remains future-only.
Intentional omissions for the first build: no interactive `menu`, no `startover`, no generated `project_orchestrator.md`, no exact `template_info.md`, no eager competitor reports during init, and no upstream license/authorship/citation parity documents.
Codex-native difference: `run --exec` invokes `codex exec` directly against the generated bounded prompt packet instead of requiring a separate manual command.
Codex-native difference: `run <role>` invokes `codex exec` by default against the generated bounded prompt packet. `--dry-run` and `--print-prompt` are the non-executing inspection paths.
Future-only features are not implemented in this build: planner/`next`, telemetry, parallel orchestration, changed-file tracking, prompt-size metrics, and hard output-ownership enforcement.
+2 -2
View File
@@ -4,10 +4,10 @@ Use the canonical TypeScript CLI with direct Codex execution:
```bash
npm exec open-gamestudio -- init --name "My Game" --engine godot --mode prototype --non-interactive --competitor "Mini Metro"
npm exec open-gamestudio -- run market_analyst --project projects/my-game --task "Create the initial market overview." --exec
npm exec open-gamestudio -- run producer --project projects/my-game "Create the initial market overview."
```
For inspection-only runs, omit `--exec` or add `--dry-run` to view the generated Codex prompt packet and metadata path before execution.
For inspection-only runs, add `--dry-run` or `--print-prompt` to view the generated Codex prompt packet and metadata path before execution.
Intentional differences: no interactive menu, no `startover`, no exact `template_info.md`, no eager competitor reports during init, and no generated `project_orchestrator.md`.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,874 @@
# Codex Game Studio Deep Integration Implementation Plan
> **For Hermes:** Do not implement this plan until Merlin explicitly approves it. When approved, use subagent-driven-development skill to implement this plan task-by-task. Do not rewrite git history or push commits unless Merlin explicitly asks.
**Goal:** Reorient Open GameStudio into a Codex-native game-development workflow layer, even if that reduces compatibility with Claude Code, OpenCode, or generic agent backends.
**Architecture:** Codex becomes the required runtime spine rather than an optional `--exec` backend. The CLI routes studio roles and workflows through a structured Codex session model, generated projects gain Codex-native instruction/state files, and validation checks Codex readiness plus prompt/workflow rendering. Keep the first implementation file-backed and package-friendly; defer databases, parallel orchestration, and broad backend abstractions until they are proven necessary.
**Tech Stack:** TypeScript, NodeNext ESM, npm CLI package, Vitest, Codex CLI, file-backed JSON/Markdown project state.
---
## Product Direction
Codex Game Studio is not a generic multi-agent framework. It is a Codex-native game-development workflow layer.
Accepted tradeoffs:
- Codex CLI is required for normal execution; commands that run or validate the runtime must fail hard when Codex is unavailable.
- `run <role>` invokes Codex by default in the first implementation pass. There is no transitional `--exec` release.
- Role prompts, project files, and workflows may be optimized specifically for Codex behavior.
- Claude Code/OpenCode/model-agnostic compatibility is not a design constraint.
- Existing compatibility surfaces should be deleted or rewritten when they conflict with the Codex-first product. This repo is new; do not preserve compatibility for its own sake.
Non-goals for the first implementation pass:
- No database-backed scheduler/task store.
- No parallel multi-agent orchestration.
- No Claude Code/OpenCode adapters.
- No backwards-compatibility layer for legacy agent IDs, `.gamestudio/*`, `project-config.json` authority, or `--exec` semantics.
- No full package/repo rename until the runtime behavior matches the new identity.
- No hidden git commits or pushes during implementation unless Merlin explicitly asks.
## Authoritative Decisions
These decisions are final for this plan and should not be re-litigated during implementation:
1. **Breaking runtime change now:** `open-gamestudio run <role> ...` launches Codex by default. `--dry-run` / `--print-prompt` are the non-executing inspection paths. Remove or repurpose legacy `--exec` behavior instead of preserving it.
2. **No legacy role migration:** Replace old agent IDs such as `producer_agent`, `mechanics_developer`, and `qa_agent` with canonical `StudioRoleId` values. Do not add aliases for backwards compatibility.
3. **`.codex/studio.json` is authoritative:** Generated projects use `.codex/studio.json` as the source of truth for engine, milestone, roles, workflows, and task state references. Delete or rewrite code that treats `project-config.json` as authoritative.
4. **Codex state lives under `.codex/`:** Runtime prompt caches and run metadata go to `.codex/runs`. Delete `.gamestudio/runs` and do not create new `.gamestudio` state.
5. **Project state writes are project-scoped:** task/workflow commands require `--project projects/<slug>` unless the current working directory is already a valid generated project root containing `.codex/studio.json`.
6. **Verification commands are structured argv:** Store and execute verification as `{ command: string; args: string[] }` with `shell: false`, a project-root cwd, timeout, and bounded output capture.
7. **Review/fix automation is schema-driven:** Review passes must return machine-readable JSON. Fix passes run only from verification failure or parsed review blockers.
8. **Local development uses npm scripts:** Do not use `npx vitest run` in this plan. Use `npm test -- <test files>` plus `npm run typecheck` and `npm run validate`.
9. **Missing Codex is fatal:** `validate` and normal execution fail when Codex CLI is missing or not authenticated. Dry-run/print-prompt may still render without launching Codex.
10. **Default fix automation is bounded:** default `--max-fix-passes` is `1`; `0` disables automatic fixes; user-provided higher values must still be finite.
## Target User Experience
Installed/link package UX:
```bash
open-gamestudio run producer --project projects/rogue-core "Create a vertical-slice plan for a roguelike"
open-gamestudio run gameplay-programmer --project projects/rogue-core "Implement movement"
open-gamestudio review --project projects/rogue-core
open-gamestudio task create --project projects/rogue-core "Add player jump" --role gameplay-programmer --verify-command npm --verify-arg test
open-gamestudio task run --project projects/rogue-core task-001 --review --fix
```
Repo-local examples must use npm scripts or the built CLI, not bare `open-gamestudio`, until the package is linked/installed. For example: `npm run build && node dist/cli.js run producer --project projects/rogue-core "Plan milestone"`.
The package/binary name may remain `open-gamestudio` initially. Public docs and CLI help should describe the product as **Codex Game Studio** as soon as this plan lands, because the Codex runtime is the default.
## Proposed End-State Source Layout
```text
src/
cli.ts
codex-runtime.ts # spawn Codex, detect CLI/auth, feed prompt files/stdin
codex-session.ts # structured Codex studio session contract
codex-prompts.ts # render role/workflow prompts from structured data
roles.ts # Codex-optimized studio role packages
workflows.ts # vertical-slice, task, review, fix, ship-check workflows
engines.ts # engine-specific Codex context and verification hints
projects.ts # project init/layout generation
validation.ts # package checks + Codex readiness/project contract checks
```
`runner.ts` should become a thin coordinator or be absorbed by `codex-session.ts`/`codex-runtime.ts`. Do not preserve legacy execution paths only for compatibility.
## Generated Project Contract
Generated game projects should become Codex-native. The full end-state layout is:
```text
AGENTS.md
.codex/
studio.json # authoritative generated-project state
tasks.json # file-backed task store, added in Phase 4
runs/ # prompt caches and run metadata; replaces .gamestudio/runs
prompts/
creative-director.md
producer.md
game-designer.md
gameplay-programmer.md
engine-programmer.md
tools-programmer.md
technical-artist.md
narrative-designer.md
qa-playtester.md
release-manager.md
workflows/
vertical-slice.md
bugfix.md
playtest.md
ship-check.md
```
Phase 2 only needs to create the declared minimum prompt/workflow subset listed in Task 8. Do not claim the full end-state layout is complete until all listed role and workflow files are generated and validated.
`AGENTS.md` is the primary Codex instruction surface and should include:
- project goal and genre
- selected engine
- coding conventions
- asset conventions
- build/test/run commands
- current milestone
- known constraints
- role routing rules
- verification expectations
Per repo rules, only `src/agents.ts` owns generated project `AGENTS.md`; `src/projects.ts` may call helpers but must not contain generated `AGENTS.md` body text.
## Structured Session Contract
Add a structured session model before expanding workflows:
```ts
export type CodexStudioPhase = "plan" | "implement" | "review" | "fix" | "ship";
export type CodexSandboxMode = "read-only" | "workspace-write" | "danger-full-access";
export type VerificationCommand = {
command: string;
args: string[];
};
export type CodexStudioSession = {
projectRoot: string;
role: StudioRoleId;
objective: string;
phase: CodexStudioPhase;
engine?: EngineId;
contextFiles: string[];
expectedOutputs: string[];
verification?: VerificationCommand;
allowFileEdits: boolean;
sandbox: CodexSandboxMode;
reviewMode?: "none" | "diff" | "full";
};
```
Prompt rendering should consume this object. Avoid ad-hoc string concatenation spread through CLI handlers.
Sandbox defaults are part of the session contract:
- `plan`, `review`, and `ship` default to `read-only` and `allowFileEdits: false`.
- `implement` and `fix` default to `workspace-write` and `allowFileEdits: true`.
- `danger-full-access` is never a default and requires an explicit CLI flag.
- Validation must reject any session where `allowFileEdits: false` produces writable sandbox args.
## Role Package Contract
Codex role packages should be structured, not just loose markdown:
```ts
export type CodexRolePackage = {
id: StudioRoleId;
displayName: string;
systemPrompt: string;
contextStrategy: "minimal" | "focused" | "broad";
expectedOutputs: string[];
handoffTemplate: string;
reviewChecklist: string[];
};
```
Initial roles:
- `creative-director`
- `producer`
- `game-designer`
- `narrative-designer`
- `gameplay-programmer`
- `engine-programmer`
- `tools-programmer`
- `technical-artist`
- `qa-playtester`
- `release-manager`
Each role should produce Codex-friendly task prompts with concrete output contracts, not vague “act as this role” text. Legacy `AgentName` identifiers are removed rather than aliased.
---
## Phase 1: Make Codex the Default Runtime Spine
**Objective:** Replace the current “Codex as an execution option” feel with a centralized Codex runtime and default Codex execution path.
### Task 1: Add Codex Runtime Detection Tests
**Objective:** Define the expected Codex runtime detection behavior before implementation.
**Files:**
- Create or modify: `tests/codex-runtime.test.ts`
- Later create: `src/codex-runtime.ts`
**Step 1: Write failing tests**
Add tests for:
- `resolveCodexCommand()` uses `CODEX_BIN` if provided.
- `resolveCodexCommand()` defaults to `codex`.
- `resolveCodexCommand()` does not read legacy Open GameStudio env names.
- `buildCodexExecArgs()` uses `exec`, `--cd <projectRoot>`, sandbox args, and stdin/file prompt mode.
- `checkCodexAvailability()` returns structured diagnostics for tests and error messages.
- normal execution and `validate` fail hard when Codex is unavailable or unauthenticated.
Expected shape:
```ts
expect(buildCodexExecArgs({ projectRoot: "/repo", sandbox: "read-only" })).toContain("exec");
expect(buildCodexExecArgs({ projectRoot: "/repo", sandbox: "read-only" })).toContain("--cd");
expect(buildCodexExecArgs({ projectRoot: "/repo", sandbox: "read-only" })).toContain("/repo");
```
**Step 2: Run focused test**
Run:
```bash
npm test -- tests/codex-runtime.test.ts
```
Expected: FAIL because `src/codex-runtime.ts` does not exist yet.
### Task 2: Implement `src/codex-runtime.ts`
**Objective:** Centralize Codex command resolution, argument construction, prompt feeding, and readiness checks.
**Files:**
- Create: `src/codex-runtime.ts`
- Modify: `tests/codex-runtime.test.ts`
**Implementation notes:**
Export pure helpers first:
```ts
export type CodexSandboxMode = "read-only" | "workspace-write" | "danger-full-access";
export type CodexRuntimeOptions = {
projectRoot: string;
sandbox?: CodexSandboxMode;
codexBin?: string;
};
export function resolveCodexCommand(env = process.env): string;
export function buildCodexExecArgs(options: CodexRuntimeOptions): string[];
export async function checkCodexAvailability(options?: { codexBin?: string }): Promise<CodexAvailability>;
```
Use prompt stdin/file feeding for actual execution. Do not build unsafe shell-quoted inline prompts.
**Step 1:** Implement pure helpers.
**Step 2:** Run:
```bash
npm test -- tests/codex-runtime.test.ts
```
Expected: PASS.
### Task 3: Route Existing `run` Execution Through `codex-runtime.ts`
**Objective:** Make existing direct execution use the new runtime rather than local process-spawning logic in `runner.ts`.
**Files:**
- Modify: `src/runner.ts`
- Modify: `src/cli.ts` if execution flags are parsed there
- Modify: `tests/runner-prompts.test.ts`
**Step 1: Write/adjust failing test**
Add tests that verify `run <role>` launches Codex by default through `codex-runtime.ts`, while `--dry-run` and `--print-prompt` render without launching Codex. Remove or rewrite tests that preserve legacy `--exec` behavior.
**Step 2: Implement minimal wiring**
- Keep dry-run/prompt-render behavior as the only non-executing path.
- Move Codex process arg construction to `codex-runtime.ts`.
- Remove legacy `--exec` semantics instead of preserving them for compatibility.
- Store prompt caches and run metadata under `.codex/runs`, not `.gamestudio/runs`.
**Step 3: Run focused tests**
```bash
npm test -- tests/runner-prompts.test.ts tests/codex-runtime.test.ts
```
Expected: PASS.
### Task 4: Update CLI Help and README Positioning
**Objective:** Make user-facing copy say Codex is the studio runtime, not merely an optional backend.
**Files:**
- Modify: `src/cli.ts`
- Modify: `README.md`
- Modify docs mentioning Claude/generic compatibility if directly contradicted by the new runtime direction.
**Copy direction:**
- Product label: **Codex Game Studio**.
- Package/binary can remain `open-gamestudio` for this phase.
- Say “requires Codex CLI for execution and validation.”
- Say “use `--dry-run` or `--print-prompt` to inspect prompts without launching Codex.”
- Remove docs that present Claude/OpenCode/generic backends or `--exec` as supported compatibility paths.
**Step 1:** Update tests that assert help/readme text if present.
**Step 2:** Update implementation/docs.
**Step 3:** Run:
```bash
npm test
npm run typecheck
npm run validate
```
Expected: all PASS.
---
## Phase 2: Add Codex-Native Project Files
**Objective:** Generated projects should contain Codex-specific instruction and state files.
### Task 5: Add Project Layout Tests for `AGENTS.md` and `.codex/studio.json`
**Files:**
- Modify: `tests/project-workflow.test.ts`
- Modify later: `src/projects.ts`
- Modify later: `src/agents.ts`
**Step 1: Write failing tests**
Assert that project init creates:
- `AGENTS.md`
- `.codex/studio.json`
- `.codex/runs/`
- `.codex/prompts/producer.md`
- `.codex/prompts/gameplay-programmer.md`
- `.codex/workflows/vertical-slice.md`
- no `.gamestudio/runs` directory
- no authoritative `project-config.json` dependency
Expected: FAIL until generation is implemented.
### Task 6: Implement `AGENTS.md` Generation
**Objective:** Generate a primary Codex instruction file for each game project.
**Files:**
- Modify: `src/projects.ts` only to call helpers; generated instruction body text must not live there
- Modify: `src/agents.ts` for all generated `AGENTS.md` changes; this is mandatory because `src/agents.ts` owns generated project `AGENTS.md`
- Test: `tests/project-workflow.test.ts`
**Required `AGENTS.md` sections:**
```md
# <Project Name> Agents
## Project Goal
## Engine
## Commands
## Coding Conventions
## Asset Conventions
## Studio Roles
## Current Milestone
## Verification
```
**Step 1:** Implement deterministic markdown generation.
**Step 2:** Ensure `AGENTS.md` contains the full project instruction contract from `src/agents.ts`; do not duplicate `AGENTS.md` body text in `src/projects.ts`.
**Step 3:** Run focused tests.
### Task 7: Implement `.codex/studio.json`
**Objective:** Add minimal file-backed project state and make it authoritative.
**Files:**
- Modify: `src/projects.ts`
- Modify or replace `src/config.ts` readers that treat `project-config.json` as authoritative
- Create or modify: `src/codex-session.ts` if shared types are introduced here
- Test: `tests/project-workflow.test.ts`
Initial JSON shape:
```json
{
"schemaVersion": 1,
"product": "codex-game-studio",
"engine": "godot",
"currentMilestone": "prototype",
"roles": [],
"workflows": []
}
```
Use deterministic formatting. `.codex/studio.json` is the generated project source of truth. Do not keep `project-config.json` as an authority; delete it or reduce it to derived output only if a later package test proves it is still needed.
### Task 8: Generate Codex Prompt and Workflow Templates
**Objective:** Ship project-local prompt/workflow files that Codex can use as durable context.
**Files:**
- Modify: `src/projects.ts`
- Modify or create: `src/roles.ts` only for the Phase 2 minimum constants, or move Task 10 before this task if full structured role packages are needed
- Modify or create: `src/workflows.ts` only for the Phase 2 minimum constants, or move Task 10 before this task if full structured workflows are needed
- Test: `tests/project-workflow.test.ts`
Required initial prompt files:
- `.codex/prompts/producer.md`
- `.codex/prompts/gameplay-programmer.md`
- `.codex/prompts/qa-playtester.md`
Required initial workflow files:
- `.codex/workflows/vertical-slice.md`
- `.codex/workflows/bugfix.md`
- `.codex/workflows/playtest.md`
---
## Phase 3: Structured Codex Sessions and Role Packages
**Objective:** Replace loose prompt assembly with structured sessions and role packages.
### Task 9: Add `src/codex-session.ts`
**Files:**
- Create: `src/codex-session.ts`
- Create or modify: `tests/codex-session.test.ts`
**Step 1: Write failing tests**
Test that a session requires:
- project root
- role
- objective
- phase
- expected outputs
Test invalid role/phase rejection if runtime validation exists.
**Step 2: Implement types and small validation helpers**
Keep this lightweight. Do not add a schema dependency unless one already exists in the project.
### Task 10: Add `src/roles.ts`
**Files:**
- Create: `src/roles.ts`
- Create or modify: `tests/roles.test.ts`
**Required exports:**
```ts
export type StudioRoleId =
| "creative-director"
| "producer"
| "game-designer"
| "narrative-designer"
| "gameplay-programmer"
| "engine-programmer"
| "tools-programmer"
| "technical-artist"
| "qa-playtester"
| "release-manager";
export type CodexRolePackage = {
id: StudioRoleId;
displayName: string;
systemPrompt: string;
contextStrategy: "minimal" | "focused" | "broad";
expectedOutputs: string[];
handoffTemplate: string;
reviewChecklist: string[];
};
```
Tests should verify all role IDs have packages and no package has empty prompt/checklist fields.
### Task 11: Add Prompt Renderer
**Files:**
- Create: `src/codex-prompts.ts`
- Create or modify: `tests/codex-prompts.test.ts`
- Modify: `src/runner.ts`
**Objective:** Render a complete Codex prompt from a `CodexStudioSession` and role package.
Prompt should include:
- role identity
- project root
- objective
- phase
- engine hints if present
- context files
- expected outputs
- verification command
- explicit instruction to report changed files and verification results
### Task 12: Refactor Runner to Use Sessions
**Files:**
- Modify: `src/runner.ts`
- Modify: `tests/runner-prompts.test.ts`
**Objective:** `runner.ts` should construct a `CodexStudioSession`, render it, then call `codex-runtime.ts`.
Verification:
```bash
npm test -- tests/codex-session.test.ts tests/codex-prompts.test.ts tests/runner-prompts.test.ts
npm run typecheck
```
---
## Phase 4: Add File-Backed Studio Tasks and Workflows
**Objective:** Move beyond one-off role invocation into a Codex-powered production loop.
### Task 13: Add Task Store Types
**Files:**
- Create: `src/tasks.ts`
- Create: `tests/tasks.test.ts`
Task shape:
```ts
export type StudioTask = {
id: string;
title: string;
role: StudioRoleId;
status: "ready" | "running" | "blocked" | "done";
files: string[];
verification?: VerificationCommand;
notes: string[];
};
```
Store path:
```text
<projectRoot>/.codex/tasks.json
```
Keep deterministic JSON and stable IDs. Do not add a database. Allocate IDs by scanning existing tasks and writing the next zero-padded ID (`task-001`, `task-002`, ...). Write atomically by writing a temp file in `.codex/` and renaming it into place. Tests must cover invalid JSON, duplicate IDs, and atomic write failure cleanup.
### Task 14: Add `task create` CLI Command
**Files:**
- Modify: `src/cli.ts`
- Modify or create: `src/tasks.ts`
- Test: add CLI/task tests in existing test style
Command:
```bash
open-gamestudio task create --project projects/rogue-core "Add player jump" --role gameplay-programmer --verify-command npm --verify-arg test
```
Expected behavior:
- Requires `--project projects/<slug>` unless cwd is a generated project root containing `.codex/studio.json`.
- Creates `<projectRoot>/.codex/tasks.json` if absent.
- Appends a ready task.
- Prints task ID.
- Does not invoke Codex.
### Task 15: Add `task run` CLI Command
**Files:**
- Modify: `src/cli.ts`
- Modify: `src/tasks.ts`
- Modify: `src/runner.ts`
- Tests: CLI/task runner tests
Command:
```bash
open-gamestudio task run --project projects/rogue-core task-001 --dry-run
open-gamestudio task run --project projects/rogue-core task-001
```
Expected behavior:
- Requires `--project projects/<slug>` unless cwd is a generated project root containing `.codex/studio.json`.
- Loads task from `<projectRoot>/.codex/tasks.json`.
- Builds a `CodexStudioSession`.
- In dry-run, prints prompt/session without Codex execution and does not mutate status.
- In normal mode, invokes Codex runtime.
- Updates task status with this state table: `ready -> running` before Codex launch; `running -> done` only when Codex exits 0 and verification passes; `running -> blocked` when Codex exits nonzero, verification fails, review has blockers after fix passes, or output parsing is malformed. Interrupted runs must leave enough metadata to report uncertainty and should not be silently marked `done`.
### Task 16: Add Workflow Prompt Commands
**Files:**
- Create or modify: `src/workflows.ts`
- Modify: `src/cli.ts`
- Tests: workflow command tests
Commands:
```bash
open-gamestudio plan vertical-slice --project projects/example --dry-run
open-gamestudio review --project projects/example --dry-run
open-gamestudio ship-check --project projects/example --dry-run
```
Start with dry-run/rendering correctness. Actual Codex execution can reuse `codex-runtime.ts`.
---
## Phase 5: Add Review/Fix Automation
**Objective:** Make Codex implementation loops first-class: implement, verify, review, optionally fix.
### Task 17: Add Verification Command Runner Boundary
**Files:**
- Create or modify: `src/verification.ts`
- Tests: `tests/verification.test.ts`
**Objective:** Run user-provided verification commands and capture stdout/stderr/exit code in a structured result.
Verification command contract:
```ts
export type VerificationCommand = {
command: string;
args: string[];
};
export type VerificationResult = {
command: string;
args: string[];
cwd: string;
exitCode: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
timedOut: boolean;
};
```
Execute with `spawn`/`execFile`, `shell: false`, `cwd = projectRoot`, a finite timeout, and bounded stdout/stderr capture. Do not parse shell strings. Tests must cover spaces, quotes, nonzero exit, timeout, cwd, and output truncation.
### Task 18: Add `--verify` to Run/Task Execution
**Files:**
- Modify: `src/cli.ts`
- Modify: `src/runner.ts`
- Modify: `src/tasks.ts`
- Tests: runner/task tests
Behavior:
```bash
open-gamestudio run gameplay-programmer --project projects/rogue-core "Add player jump" --verify-command npm --verify-arg test
```
After Codex exits, run verification and include result in final CLI output.
### Task 19: Add `--review` Prompt Pass
**Files:**
- Modify: `src/runner.ts`
- Modify: `src/codex-prompts.ts`
- Tests: prompt/runner tests
Behavior:
- After implementation and verification, render a review prompt.
- Review prompt asks Codex to inspect the diff and verification output.
- Review pass must return machine-readable JSON, preferably through Codex `--output-schema` when available. Minimum schema:
```json
{
"blockers": [],
"warnings": [],
"summary": "",
"needsFix": false
}
```
- Dry-run mode shows implementation prompt, review prompt, and expected schema separately.
- Malformed review JSON is treated as blocked/uncertain, not success.
### Task 20: Add `--fix --max-fix-passes <n>`
**Files:**
- Modify: `src/cli.ts`
- Modify: `src/runner.ts`
- Tests: runner loop tests
Behavior:
- If verification fails or parsed review JSON reports blockers, run a fix prompt.
- Default max fix passes: 1. `--max-fix-passes 0` disables automatic fixes.
- Always stop after max passes and report remaining blockers.
- Do not silently loop forever.
- Tests must cover: no blockers -> no fix pass; blockers -> one fix pass by default; malformed review output -> stop blocked; failed verification after max passes -> report blocked.
---
## Phase 6: Engine-Aware Codex Context
**Objective:** Make engine configs useful for Codex prompts and validation.
### Task 21: Extend Engine Config Schema
**Files:**
- Modify: `engine_configs/godot.json`
- Modify: `engine_configs/unity.json`
- Modify: `engine_configs/unreal.json`
- Modify: `src/engines.ts`
- Tests: engine system tests
Add fields:
```json
{
"project_files": ["project.godot", "scenes/**", "scripts/**"],
"run_command": "godot --path .",
"test_command": "godot --headless --path . --run-tests",
"codex_hints": [
"Prefer engine-native idioms.",
"Do not edit imported assets directly.",
"Describe binary scene/asset changes explicitly."
]
}
```
Use realistic commands per engine, and mark commands as examples if they depend on local installation. Keep engine config keys snake_case to match the existing JSON style, and update `engineConfigSchema` before prompt rendering uses the new fields.
### Task 22: Inject Engine Hints into Prompt Renderer
**Files:**
- Modify: `src/codex-prompts.ts`
- Modify tests: `tests/codex-prompts.test.ts`
Expected behavior:
- Prompt includes engine-specific files, commands, and Codex hints.
- Missing engine config does not crash dry-run; it produces a clear warning.
---
## Phase 7: Validation and Packaging Hardening
**Objective:** Ensure the package validates Codex readiness and ships all runtime assets.
### Task 23: Add Codex Readiness Checks to `validate`
**Files:**
- Modify: `src/validation.ts`
- Tests: `tests/validation.test.ts`
Checks:
- `codex.cli`: Codex command exists and is authenticated; missing/unusable Codex is a validation failure.
- `codex.project.AGENTS.md`: generated project contract exists when validating a project.
- `codex.roles`: all required role packages render.
- `codex.workflow.vertical-slice`: workflow prompt renders.
- `codex.engine.<engine>`: engine hints/config parse.
### Task 24: Verify Package Shipping
**Files:**
- Modify: `package.json` if `files` misses runtime assets
- Modify: validation/package tests if present
Required checks:
- Build before black-box CLI tests.
- `npm pack` includes Codex role/workflow assets if they are stored outside TypeScript source.
- Temporary install can run `open-gamestudio validate` from a non-repo cwd.
Commands:
```bash
npm run build
npm pack --dry-run
npm run validate
```
---
## Phase 8: Naming Decision
**Objective:** Decide whether to rename repo/package after Codex-native behavior lands.
Defer until after Phases 13 are complete.
Options:
1. Full rename:
- repo: `codex-game-studio`
- package: `codex-game-studio`
- binary: `codex-game-studio`
2. Product-only rename:
- repo/package/binary remain `open-gamestudio`
- README title: **Codex Game Studio**
- tagline: “A Codex-native AI game studio workflow.”
Recommendation: start with product-only rename, then full rename once commands and validation are truly Codex-first.
---
## Global Verification Gate
Before proposing any commit for review, run:
```bash
npm test
npm run typecheck
npm run validate
```
If package/runtime assets changed, also run:
```bash
npm run build
npm pack --dry-run
```
Before claiming parity or package readiness, follow the repo instruction in `AGENTS.md`: use `npm run validate`.
## Resolved Implementation Decisions
- `run <role>` invokes Codex by default in Phase 1. No transitional `--exec` compatibility release.
- Legacy agent IDs and old state layouts are removed instead of migrated.
- `.codex/studio.json` is authoritative generated-project state.
- Runtime caches and run metadata live in `.codex/runs`; `.gamestudio/runs` is deleted.
- `validate` fails when Codex CLI is missing or unusable.
- Task and workflow state writes are scoped to `--project projects/<slug>` unless cwd is a valid generated project root.
- `task run` mutates task status using the explicit state table in Task 15.
- Default automatic fix passes: `1`.
- Initial engine configs remain Godot, Unity, and Unreal for this plan; add web/Three.js only in a later plan if needed.
- Package/repo rename remains deferred; product copy changes now.
## Handoff Notes
- This is a review plan only. No implementation should start until Merlin approves.
- Do not commit or push the plan unless Merlin explicitly asks.
- When implementing, prefer Codex-authored source edits if Merlin wants the project to dogfood Codex deeply.
- Keep each implementation PR narrow. The first PR should likely be Phase 1 only.
+6 -4
View File
@@ -2,15 +2,17 @@
Validation exits nonzero when any check fails.
Repo validation checks package scripts, build output, NodeNext import specifiers, package assets, engine configs, base agents, templates, package packing, installed-bin asset loading, and direct Codex execution exposure.
Repo validation checks package scripts, build output, package assets, engine configs, expanded role rendering, canonical workflow rendering, templates, package packing, installed-bin asset loading, future-only CLI surfaces, and Codex CLI readiness.
Project validation checks schema-valid config, active agents, engine source root, engine project file, materialized agents, project `AGENTS.md` provenance and config hash, market seed, starter GDD, timeline sections, and read-only `status`/`resume` behavior.
Project validation checks `.codex/studio.json` full `roles`, mode-specific `activeRoles`, registry-derived `workflows`, `AGENTS.md`, generated project-specific role prompts, workflow files, engine source files, starter docs, timeline sections, forbidden legacy artifacts, and read-only `status`/`resume` behavior.
CLI surface checks:
```bash
npm exec open-gamestudio -- run --help | grep -- "--exec"
npm exec open-gamestudio -- run --help | grep -- "--dry-run"
! npm exec open-gamestudio -- --help | grep -E " next|telemetry"
```
No generated `project_orchestrator.md` is required or produced.
Workflow shortcut commands such as `market`, `analytics`, `design-spec`, `feel-review`, `art-direction`, `ui-review`, `milestone`, and `handoff` render prompts only. They do not launch Codex or create run records.
No generated `CODEX.md`, `.gamestudio/runs`, or `project_orchestrator.md` is required or produced.
+7
View File
@@ -6,6 +6,13 @@
"source_root_pattern": "source/project-{slug}",
"folders": ["assets", "scenes", "scripts"],
"project_files": ["project.godot"],
"run_command": "godot --path .",
"test_command": "godot --headless --path . --run-tests",
"codex_hints": [
"Prefer Godot scene/node composition and GDScript-friendly boundaries.",
"Do not edit imported assets directly.",
"Describe binary scene/resource changes explicitly."
],
"best_practices": ["Use scenes for composition.", "Keep gameplay scripts focused."],
"agent_specializations": {
"summary": "Godot project using scenes, nodes, resources, and GDScript-friendly architecture.",
+7
View File
@@ -6,6 +6,13 @@
"source_root_pattern": "source/project-{slug}",
"folders": ["Assets", "Packages", "ProjectSettings"],
"project_files": ["Packages/manifest.json", "ProjectSettings/ProjectSettings.asset"],
"run_command": "Unity -projectPath .",
"test_command": "Unity -batchmode -projectPath . -runTests",
"codex_hints": [
"Prefer Unity component patterns and keep gameplay scripts under Assets.",
"Do not edit imported assets directly.",
"Describe scene, prefab, and ProjectSettings changes explicitly."
],
"best_practices": ["Keep gameplay code under Assets.", "Track ProjectSettings markers."],
"agent_specializations": {
"summary": "Unity project using Assets, Packages, and ProjectSettings layout.",
+7
View File
@@ -6,6 +6,13 @@
"source_root_pattern": "source/project-{slug}",
"folders": ["Content", "Config", "Source"],
"project_files": ["<ProjectClass>.uproject"],
"run_command": "UnrealEditor <ProjectClass>.uproject",
"test_command": "UnrealEditor-Cmd <ProjectClass>.uproject -ExecCmds=\"Automation RunTests Project\" -unattended",
"codex_hints": [
"Prefer Unreal module, Blueprint, and Content folder conventions.",
"Do not edit imported assets directly.",
"Describe binary map, Blueprint, and asset changes explicitly."
],
"best_practices": ["Keep gameplay modules explicit.", "Use Content for assets and Config for project defaults."],
"agent_specializations": {
"summary": "Unreal Engine project using Content, Config, Source, and a PascalCase .uproject file.",
+1 -2
View File
@@ -1,7 +1,7 @@
{
"name": "open-gamestudio",
"version": "0.1.0",
"description": "Provider-neutral CLI workflow layer for agent-assisted game projects.",
"description": "Codex-native CLI workflow layer for game projects.",
"author": "MerlinH",
"license": "MIT",
"type": "module",
@@ -11,7 +11,6 @@
"files": [
"dist/",
"engine_configs/",
"agents/base/",
"templates/"
],
"bin": {
+108 -54
View File
@@ -1,8 +1,8 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { agentNames, guidanceConfigHash, type AgentName, type ProjectConfig } from "./config.js";
import { guidanceConfigHash, type ProjectConfig } from "./config.js";
import type { EngineConfigRegistry } from "./engines.js";
import { packageAssetPath } from "./paths.js";
import { rolePackages, studioRoleIds, type StudioRoleId } from "./roles.js";
export type MaterializeAgentsInput = {
projectRoot: string;
@@ -11,30 +11,29 @@ export type MaterializeAgentsInput = {
};
export function validateBaseAgents(): string[] {
return agentNames.flatMap((agent) => {
const file = packageAssetPath(`agents/base/${agent}.md`);
if (!existsSync(file)) return [`Missing base agent ${agent}`];
const body = readFileSync(file, "utf8");
return ["# Role", "# Inputs", "# Outputs", "# Validation", "# Engine Notes", "# Rules"].filter((section) => !sectionHasContent(body, section)).map((section) => `${agent} missing non-empty ${section}`);
return studioRoleIds.flatMap((role) => {
const pkg = rolePackages[role];
return pkg.systemPrompt.trim() && pkg.expectedOutputs.length && pkg.reviewChecklist.length ? [] : [`Invalid role package ${role}`];
});
}
function sectionHasContent(body: string, section: string): boolean {
const escaped = section.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = new RegExp(`^${escaped}\\s*$`, "m").exec(body);
if (!match) return false;
const start = match.index + match[0].length;
const rest = body.slice(start);
const nextHeading = rest.search(/^#/m);
const content = nextHeading === -1 ? rest : rest.slice(0, nextHeading);
return content.trim().length > 0;
export function readAgentPrompt(agent: StudioRoleId, projectRoot?: string): string {
const projectPrompt = projectRoot ? path.join(projectRoot, ".codex", "prompts", `${agent}.md`) : "";
if (projectPrompt && existsSync(projectPrompt)) return readFileSync(projectPrompt, "utf8");
return rolePackages[agent].systemPrompt;
}
export function readAgentPrompt(agent: AgentName, projectRoot?: string): string {
const projectPrompt = projectRoot ? path.join(projectRoot, ".gamestudio", "agents", `${agent}.md`) : "";
if (projectPrompt && existsSync(projectPrompt)) return readFileSync(projectPrompt, "utf8");
return readFileSync(packageAssetPath(`agents/base/${agent}.md`), "utf8");
}
export const projectAgentsMdRequiredSections = [
"## Project Goal",
"## Engine",
"## Commands",
"## Coding Conventions",
"## Asset Conventions",
"## Studio Roles",
"## Current Milestone",
"## Verification",
"## Rules"
] as const;
export function generateProjectAgentsMd(config: ProjectConfig): string {
const hash = guidanceConfigHash(config);
@@ -44,53 +43,108 @@ export function generateProjectAgentsMd(config: ProjectConfig): string {
Project: ${config.project.name}
Slug: ${config.project.slug}
Engine: ${config.project.engine}
Mode: ${config.project.mode}
# Validation
## Project Goal
Run \`npm run validate -- --project projects/${config.project.slug}\`.
${config.project.concept}
# Agent Prompts
## Engine
${config.team.active_agents.map((agent) => `- ${agent}: .gamestudio/agents/${agent}.md`).join("\n")}
${config.project.engine} ${config.project.engine_version}
# Rules
## Commands
Use bounded context. Load the current role prompt, project config, engine overlay, and task-relevant templates only.
Codex execution is first-class: prefer \`open-gamestudio run <agent> --exec\` when you want the toolkit to invoke Codex directly; use \`--dry-run\` or \`--print-prompt\` only for inspection.
- Validate: \`npm run validate -- --project projects/${config.project.slug}\`
## Coding Conventions
- Prefer engine-native idioms.
- Keep generated code scoped to the current task.
## Asset Conventions
- Do not edit imported binary assets without documenting the change.
- Describe scene, prefab, and material changes explicitly.
## Studio Roles
${studioRoleIds.map((role) => `- ${role}: .codex/prompts/${role}.md`).join("\n")}
## Current Milestone
${config.project.mode === "design" ? "design" : config.project.mode === "development" ? "development" : "prototype"}
## Verification
Run project validation before claiming parity or readiness.
## Rules
Use AGENTS.md, .codex/studio.json, the current role prompt, and task-relevant context only.
Codex is the default runtime for \`open-gamestudio run <role>\`; use \`--dry-run\` or \`--print-prompt\` only for inspection.
Do not use telemetry, planner/next, parallel orchestration, or ownership enforcement in this build.
`;
}
export function renderProjectRolePrompt(role: StudioRoleId, config: ProjectConfig, engines: EngineConfigRegistry): string {
const pkg = rolePackages[role];
const engine = engines[config.project.engine];
return [
`# ${pkg.displayName}`,
"",
`Project: ${config.project.name}`,
`Slug: ${config.project.slug}`,
`Role: ${pkg.displayName}`,
`Mode: ${config.project.mode}`,
`Engine: ${engine.display_name} ${config.project.engine_version}`,
`Current Milestone: ${config.project.mode === "design" ? "design" : config.project.mode === "development" ? "development" : "prototype"}`,
"",
"## Project Summary",
"",
config.project.concept,
"",
`Genre: ${config.project.genre}`,
`Platform: ${config.project.platform}`,
`Audience: ${config.project.audience}`,
`Monetization: ${config.project.monetization}`,
`Timeline: ${config.project.timeline}`,
`Competitors: ${config.project.competitors.join(", ") || "none configured"}`,
"",
"## Role Instructions",
"",
pkg.systemPrompt,
"",
"## Engine Context",
"",
...engine.codex_hints.map((hint) => `- ${hint}`),
"",
"## Expected Outputs",
"",
...pkg.expectedOutputs.map((item) => `- ${item}`),
"",
"## Review Checklist",
"",
...pkg.reviewChecklist.map((item) => `- ${item}`),
"",
"## Handoff",
"",
pkg.handoffTemplate,
""
].join("\n");
}
export function materializeAgents(input: MaterializeAgentsInput): string[] {
const config = input.config;
const engine = input.engines[config.project.engine];
const target = path.join(input.projectRoot, ".gamestudio", "agents");
mkdirSync(target, { recursive: true });
const written: string[] = [];
for (const agent of config.team.active_agents) {
const base = readFileSync(packageAssetPath(`agents/base/${agent}.md`), "utf8");
const body = `${base}
# Project Context
- Name: ${config.project.name}
- Concept: ${config.project.concept}
- Audience: ${config.project.audience}
- Engine: ${engine.display_name} ${config.project.engine_version}
- Mode: ${config.project.mode}
# Engine Overlay
${Object.values(engine.agent_specializations).join("\n")}
`;
const file = path.join(target, `${agent}.md`);
writeFileSync(file, body);
written.push(file);
}
const agentsMd = path.join(input.projectRoot, "AGENTS.md");
writeFileSync(agentsMd, generateProjectAgentsMd(config));
writeFileSync(agentsMd, generateProjectAgentsMd(input.config));
written.push(agentsMd);
const prompts = path.join(input.projectRoot, ".codex", "prompts");
mkdirSync(prompts, { recursive: true });
for (const role of studioRoleIds) {
const prompt = path.join(prompts, `${role}.md`);
writeFileSync(prompt, renderProjectRolePrompt(role, input.config, input.engines));
written.push(prompt);
}
return written;
}
+94 -19
View File
@@ -4,7 +4,11 @@ import path from "node:path";
import { formatTemplateShow, listTemplates, templateRegistry, type TemplateId } from "./templates.js";
import { freezeProject, initProject, resumeProject, statusProject } from "./projects.js";
import { runValidation } from "./validation.js";
import { executeCodexRun, prepareRun } from "./runner.js";
import { executeRunLifecycle, prepareRun } from "./runner.js";
import { checkCodexAvailability } from "./codex-runtime.js";
import { createTask, executeTaskRun, resolveTaskProject } from "./tasks.js";
import { renderWorkflowPrompt, workflowRegistry, type WorkflowId } from "./workflows.js";
import { isStudioRoleId, unknownStudioRoleMessage } from "./roles.js";
const program = new Command();
@@ -12,7 +16,7 @@ function collectCompetitor(value: string, previous: string[] = []): string[] {
return [...previous, value.trim()].filter(Boolean);
}
program.name("open-gamestudio").description("Codex-native TypeScript game-studio toolkit").version("0.1.0");
program.name("open-gamestudio").description("Codex Game Studio: a Codex-native game-development workflow layer").version("0.1.0");
function addInitCommand(name: "init" | "new"): void {
program
@@ -84,38 +88,109 @@ templates
program
.command("run")
.description("Prepare one bounded prompt packet for a project agent, with optional direct Codex execution")
.argument("<agent>")
.description("Run a Codex Game Studio role through Codex by default")
.argument("<role>")
.argument("[objective...]")
.requiredOption("--project <path>", "project path")
.requiredOption("--task <text>", "task text")
.option("--task <text>", "task text; positional objective is preferred")
.option("--print-prompt", "print deterministic prompt body")
.option("--dry-run", "print selected context and output paths")
.option("--exec", "execute the prompt immediately with codex exec")
.option("--dry-run", "print selected context and Codex command without launching Codex")
.option("--include-artifact <relative-path>", "include one project artifact", (value, previous: string[] = []) => [...previous, value], [])
.option("--allow-broad-context", "explicitly allow broader context discovery")
.action((agent, opts) => {
const result = prepareRun(agent, {
.option("--verify-command <command>", "structured verification command")
.option("--verify-arg <arg>", "structured verification argument; repeat for multiple args", (value, previous: string[] = []) => [...previous, value], [])
.option("--review", "render/run a schema-driven review pass")
.option("--fix", "render/run bounded fix pass prompts when blocked")
.option("--max-fix-passes <count>", "maximum automatic fix passes", "1")
.action(async (role, objectiveParts: string[], opts) => {
const verifyCommand = opts.verifyCommand ? { command: opts.verifyCommand as string, args: opts.verifyArg as string[] } : undefined;
const result = prepareRun(role, {
project: opts.project,
task: opts.task,
task: opts.task ?? objectiveParts.join(" "),
printPrompt: opts.printPrompt,
dryRun: opts.dryRun,
exec: opts.exec,
includeArtifact: opts.includeArtifact,
allowBroadContext: opts.allowBroadContext
allowBroadContext: opts.allowBroadContext,
verifyCommand,
review: opts.review,
fix: opts.fix,
maxFixPasses: Number(opts.maxFixPasses)
});
console.log(result.output);
if (!opts.exec) return;
const execution = executeCodexRun(result);
if (execution.stdout) process.stdout.write(execution.stdout);
if (execution.stderr) process.stderr.write(execution.stderr);
if (execution.error) {
console.error(execution.error.message);
if (opts.dryRun || opts.printPrompt) return;
const availability = await checkCodexAvailability({ codexBin: result.codexCommand.command });
if (!availability.ok) {
console.error(availability.reason ?? "Codex CLI is unavailable or unauthenticated");
process.exitCode = 1;
return;
}
if (execution.status !== 0) process.exitCode = execution.status ?? 1;
const lifecycle = await executeRunLifecycle(result);
console.log(lifecycle.output);
if (lifecycle.finalStatus !== "done") process.exitCode = 1;
});
const task = program.command("task").description("Manage file-backed Codex studio tasks");
task
.command("create")
.description("Create a ready task in .codex/tasks.json")
.requiredOption("--project <path>", "project path")
.requiredOption("--role <role>", "studio role")
.option("--verify-command <command>", "structured verification command")
.option("--verify-arg <arg>", "structured verification argument; repeat for multiple args", (value, previous: string[] = []) => [...previous, value], [])
.argument("<title...>")
.action((titleParts: string[], opts) => {
if (!isStudioRoleId(opts.role)) throw new Error(unknownStudioRoleMessage(opts.role));
const projectRoot = resolveTaskProject(opts.project);
const verification = opts.verifyCommand ? { command: opts.verifyCommand as string, args: opts.verifyArg as string[] } : undefined;
const created = createTask(projectRoot, { title: titleParts.join(" "), role: opts.role, verification });
console.log(created.id);
});
task
.command("run")
.description("Run a task through Codex")
.requiredOption("--project <path>", "project path")
.option("--dry-run", "render task prompt without mutation")
.option("--review", "render/run a schema-driven review pass")
.option("--fix", "render/run bounded fix pass prompts when blocked")
.option("--max-fix-passes <count>", "maximum automatic fix passes", "1")
.argument("<task-id>")
.action(async (taskId: string, opts) => {
const projectRoot = resolveTaskProject(opts.project);
const result = await executeTaskRun(projectRoot, taskId, {
dryRun: opts.dryRun,
review: opts.review,
fix: opts.fix,
maxFixPasses: Number(opts.maxFixPasses)
});
console.log(opts.dryRun ? result.prepared.output : `${result.prepared.output}\n${result.lifecycle?.output ?? ""}\n${taskId} ${result.task.status}`);
if (result.lifecycle?.finalStatus === "blocked") process.exitCode = 1;
});
function renderWorkflowCommand(workflow: WorkflowId, opts: { project: string }): void {
const projectRoot = resolveTaskProject(opts.project);
console.log(renderWorkflowPrompt(projectRoot, workflow));
}
function addWorkflowCommand(name: "review" | "ship-check"): void {
program
.command(name)
.description(`Render the ${name} workflow prompt`)
.requiredOption("--project <path>", "project path")
.option("--dry-run", "render prompt without launching Codex")
.action((opts) => renderWorkflowCommand(name, opts));
}
for (const workflow of Object.values(workflowRegistry).filter((entry) => entry.cliAlias)) {
program
.command(workflow.cliAlias!)
.description(`Render the ${workflow.id} workflow prompt`)
.requiredOption("--project <path>", "project path")
.option("--dry-run", "render prompt without launching Codex")
.action((opts) => renderWorkflowCommand(workflow.id, opts));
}
addWorkflowCommand("review");
addWorkflowCommand("ship-check");
program.parseAsync().catch((error: unknown) => {
console.error((error as Error).message);
process.exitCode = 1;
+60
View File
@@ -0,0 +1,60 @@
import { rolePackages } from "./roles.js";
import type { CodexStudioSession } from "./codex-session.js";
import { loadEngineConfigs } from "./engines.js";
import { packageAssetPath } from "./paths.js";
function formatList(values: string[]): string {
return values.length ? values.map((value) => `- ${value}`).join("\n") : "- None";
}
export function renderCodexPrompt(session: CodexStudioSession): string {
const role = rolePackages[session.role];
const verification = session.verification ? `${session.verification.command} ${session.verification.args.join(" ")}`.trim() : "No verification command provided.";
const engine = session.engine ? loadEngineConfigs(packageAssetPath("engine_configs"))[session.engine] : undefined;
const engineSection = engine
? [
`Display Name: ${engine.display_name}`,
"Project Files:",
formatList(engine.project_files),
`Run Command: ${engine.run_command}`,
`Test Command: ${engine.test_command}`,
"Codex Hints:",
formatList(engine.codex_hints)
].join("\n")
: session.engine
? `Warning: engine config for ${session.engine} was not found.`
: "Engine: unspecified";
return [
"# Codex Game Studio Session",
"",
`Role: ${role.displayName}`,
`Role ID: ${role.id}`,
`Phase: ${session.phase}`,
`Project Root: ${session.projectRoot}`,
`Objective: ${session.objective}`,
session.engine ? `Engine: ${session.engine}` : "Engine: unspecified",
`Sandbox: ${session.sandbox}`,
`File Edits: ${session.allowFileEdits ? "allowed" : "not allowed"}`,
"",
"## Role Contract",
role.systemPrompt,
"",
"## Engine Context",
engineSection,
"",
"## Context Files",
formatList(session.contextFiles),
"",
"## Expected Outputs",
formatList(session.expectedOutputs),
"",
"## Verification",
verification,
"",
"## Review Checklist",
formatList(role.reviewChecklist),
"",
"## Completion Report",
"Report changed files, verification results, decisions made, and remaining risks."
].join("\n");
}
+65
View File
@@ -0,0 +1,65 @@
import { spawn, spawnSync } from "node:child_process";
import type { CodexSandboxMode } from "./codex-session.js";
export type CodexRuntimeOptions = {
projectRoot: string;
sandbox?: CodexSandboxMode;
codexBin?: string;
};
export type CodexAvailability = {
ok: boolean;
command: string;
reason?: string;
stdout: string;
stderr: string;
};
export type CodexExecutionResult = {
status: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
error?: Error;
};
export function resolveCodexCommand(env: NodeJS.ProcessEnv | Record<string, string | undefined> = process.env): string {
return env.CODEX_BIN?.trim() || "codex";
}
export function buildCodexExecArgs(options: CodexRuntimeOptions): string[] {
return ["exec", "--cd", options.projectRoot, "--sandbox", options.sandbox ?? "workspace-write", "-"];
}
export async function checkCodexAvailability(options: { codexBin?: string } = {}): Promise<CodexAvailability> {
const command = options.codexBin ?? resolveCodexCommand();
const result = spawnSync(command, ["--version"], { encoding: "utf8", shell: false });
if (result.error) {
return { ok: false, command, reason: `Codex CLI unavailable or not found: ${result.error.message}`, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
}
if (result.status !== 0) {
return { ok: false, command, reason: `Codex CLI exited with status ${result.status}`, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
}
return { ok: true, command, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
}
export async function executeCodexPrompt(prompt: string, options: CodexRuntimeOptions): Promise<CodexExecutionResult> {
const command = options.codexBin ?? resolveCodexCommand();
const args = buildCodexExecArgs(options);
return await new Promise((resolve) => {
const child = spawn(command, args, { cwd: options.projectRoot, shell: false, stdio: ["pipe", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.on("error", (error) => resolve({ status: null, signal: null, stdout, stderr, error }));
child.on("close", (status, signal) => resolve({ status, signal, stdout, stderr }));
child.stdin.end(prompt);
});
}
+71
View File
@@ -0,0 +1,71 @@
import type { EngineId } from "./engines.js";
import { isStudioRoleId, rolePackages, unknownStudioRoleMessage, type StudioRoleId } from "./roles.js";
export type CodexStudioPhase = "plan" | "implement" | "review" | "fix" | "ship";
export type CodexSandboxMode = "read-only" | "workspace-write" | "danger-full-access";
export type VerificationCommand = {
command: string;
args: string[];
};
export type CodexStudioSession = {
projectRoot: string;
role: StudioRoleId;
objective: string;
phase: CodexStudioPhase;
engine?: EngineId;
contextFiles: string[];
expectedOutputs: string[];
verification?: VerificationCommand;
allowFileEdits: boolean;
sandbox: CodexSandboxMode;
reviewMode?: "none" | "diff" | "full";
};
export type CreateCodexStudioSessionInput = {
projectRoot: string;
role: StudioRoleId;
objective: string;
phase: CodexStudioPhase;
engine?: EngineId;
contextFiles?: string[];
expectedOutputs?: string[];
verification?: VerificationCommand;
allowFileEdits?: boolean;
sandbox?: CodexSandboxMode;
reviewMode?: "none" | "diff" | "full";
};
function defaultsForPhase(phase: CodexStudioPhase): Pick<CodexStudioSession, "allowFileEdits" | "sandbox"> {
if (phase === "implement" || phase === "fix") return { allowFileEdits: true, sandbox: "workspace-write" };
return { allowFileEdits: false, sandbox: "read-only" };
}
export function validateCodexStudioSession(session: CodexStudioSession): CodexStudioSession {
if (!session.projectRoot.trim()) throw new Error("Codex studio session requires project root");
if (!isStudioRoleId(session.role)) throw new Error(unknownStudioRoleMessage(session.role));
if (!session.objective.trim()) throw new Error("Codex studio session requires objective");
if (!["plan", "implement", "review", "fix", "ship"].includes(session.phase)) throw new Error(`Unknown studio phase: ${session.phase}`);
if (session.expectedOutputs.length === 0) throw new Error("Codex studio session requires expected outputs");
if (!session.allowFileEdits && session.sandbox !== "read-only") throw new Error("Session with allowFileEdits false cannot use a writable sandbox");
return session;
}
export function createCodexStudioSession(input: CreateCodexStudioSessionInput): CodexStudioSession {
const defaults = defaultsForPhase(input.phase);
const session: CodexStudioSession = {
projectRoot: input.projectRoot,
role: input.role,
objective: input.objective,
phase: input.phase,
engine: input.engine,
contextFiles: input.contextFiles ?? ["AGENTS.md", ".codex/studio.json"],
expectedOutputs: input.expectedOutputs ?? rolePackages[input.role].expectedOutputs,
verification: input.verification,
allowFileEdits: input.allowFileEdits ?? defaults.allowFileEdits,
sandbox: input.sandbox ?? defaults.sandbox,
reviewMode: input.reviewMode
};
return validateCodexStudioSession(session);
}
+17 -26
View File
@@ -1,25 +1,13 @@
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import { z } from "zod";
import { studioRoleIds, type StudioRoleId } from "./roles.js";
export const agentNames = [
"master_orchestrator",
"producer_agent",
"market_analyst",
"data_scientist",
"sr_game_designer",
"mid_game_designer",
"mechanics_developer",
"game_feel_developer",
"sr_game_artist",
"technical_artist",
"ui_ux_agent",
"qa_agent"
] as const;
export const agentNames = studioRoleIds;
export const modeSchema = z.enum(["design", "prototype", "development"]);
export const agentNameSchema = z.enum(agentNames);
export type AgentName = z.infer<typeof agentNameSchema>;
export type AgentName = StudioRoleId;
export type ProjectMode = z.infer<typeof modeSchema>;
export const milestoneSchema = z.object({
@@ -65,19 +53,22 @@ export function slugify(value: string): string {
}
export function activeAgentsForMode(mode: ProjectMode): AgentName[] {
const always: AgentName[] = ["master_orchestrator", "producer_agent", "market_analyst", "data_scientist"];
const always: AgentName[] = ["studio-orchestrator", "producer", "market-analyst", "data-scientist"];
const byMode: Record<ProjectMode, AgentName[]> = {
design: ["sr_game_designer", "mid_game_designer", "sr_game_artist"],
prototype: ["sr_game_designer", "mechanics_developer", "qa_agent"],
design: ["creative-director", "senior-game-designer", "game-designer", "narrative-designer", "senior-game-artist", "ui-ux-designer"],
prototype: ["senior-game-designer", "game-designer", "game-feel-designer", "gameplay-programmer", "qa-playtester"],
development: [
"sr_game_designer",
"mid_game_designer",
"mechanics_developer",
"game_feel_developer",
"qa_agent",
"sr_game_artist",
"technical_artist",
"ui_ux_agent"
"senior-game-designer",
"game-designer",
"game-feel-designer",
"gameplay-programmer",
"engine-programmer",
"tools-programmer",
"qa-playtester",
"senior-game-artist",
"technical-artist",
"ui-ux-designer",
"release-manager"
]
};
return [...always, ...byMode[mode]];
+3
View File
@@ -12,6 +12,9 @@ const engineConfigSchema = z.object({
source_root_pattern: z.literal("source/project-{slug}"),
folders: z.array(z.string()),
project_files: z.array(z.string()),
run_command: z.string(),
test_command: z.string(),
codex_hints: z.array(z.string()),
best_practices: z.array(z.string()),
agent_specializations: z.record(z.string())
});
+119 -24
View File
@@ -1,9 +1,11 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { activeAgentsForMode, readProjectConfig, slugify, writeProjectConfig, type ProjectConfig, type ProjectMode } from "./config.js";
import { activeAgentsForMode, slugify, type ProjectConfig, type ProjectMode } from "./config.js";
import { createEngineFolders, createEngineProjectFiles, loadEngineConfigs, normalizeEngine, projectClassName, sourceRoot, unrealProjectFileName } from "./engines.js";
import { materializeAgents } from "./agents.js";
import { packageAssetPath, resolveProjectRoot } from "./paths.js";
import { rolePackages, studioRoleIds, type StudioRoleId } from "./roles.js";
import { workflowIds, workflowRegistry, type WorkflowId } from "./workflows.js";
export type InitProjectOptions = {
name: string;
@@ -20,6 +22,26 @@ export type InitProjectOptions = {
nonInteractive?: boolean;
};
export type StudioProjectState = {
schemaVersion: 1;
product: "codex-game-studio";
name: string;
slug: string;
concept: string;
genre: string;
platform: string;
audience: string;
engine: ProjectConfig["project"]["engine"];
engineVersion: string;
mode: ProjectMode;
phase: string;
status: "active" | "frozen" | "inactive";
currentMilestone: string;
roles: StudioRoleId[];
activeRoles: StudioRoleId[];
workflows: WorkflowId[];
};
export function defaultProjectConfig(options: InitProjectOptions): ProjectConfig {
const engines = loadEngineConfigs(packageAssetPath("engine_configs"));
const engine = normalizeEngine(options.engine, engines);
@@ -83,15 +105,15 @@ function assertNoSameParentCollision(parent: string, config: ProjectConfig): voi
const nextClass = projectClassName(config.project.name);
for (const entry of readdirSync(parent, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const configPath = path.join(parent, entry.name, "project-config.json");
if (!existsSync(configPath)) continue;
const existing = readProjectConfig(configPath);
if (existing.project.name === config.project.name) continue;
if (existing.project.slug === config.project.slug) {
throw new Error(`Project name "${config.project.name}" collides with existing slug "${existing.project.slug}" in ${parent}`);
const studioPath = path.join(parent, entry.name, ".codex", "studio.json");
if (!existsSync(studioPath)) continue;
const existing = readStudioProject(path.join(parent, entry.name));
if (existing.name === config.project.name) continue;
if (existing.slug === config.project.slug) {
throw new Error(`Project name "${config.project.name}" collides with existing slug "${existing.slug}" in ${parent}`);
}
if (existing.project.engine === "unreal" || config.project.engine === "unreal") {
const existingClass = projectClassName(existing.project.name);
if (existing.engine === "unreal" || config.project.engine === "unreal") {
const existingClass = projectClassName(existing.name);
if (existingClass === nextClass) {
throw new Error(`Project name "${config.project.name}" collides with existing Unreal class name "${existingClass}" in ${parent}`);
}
@@ -99,6 +121,78 @@ function assertNoSameParentCollision(parent: string, config: ProjectConfig): voi
}
}
export function studioStateFromConfig(config: ProjectConfig): StudioProjectState {
return {
schemaVersion: 1,
product: "codex-game-studio",
name: config.project.name,
slug: config.project.slug,
concept: config.project.concept,
genre: config.project.genre,
platform: config.project.platform,
audience: config.project.audience,
engine: config.project.engine,
engineVersion: config.project.engine_version,
mode: config.project.mode,
phase: config.project.phase,
status: config.project.status,
currentMilestone: config.project.mode === "design" ? "design" : config.project.mode === "development" ? "development" : "prototype",
roles: [...studioRoleIds],
activeRoles: activeAgentsForMode(config.project.mode),
workflows: workflowIds()
};
}
export function readStudioProject(projectRoot: string): StudioProjectState {
return JSON.parse(readFileSync(path.join(projectRoot, ".codex", "studio.json"), "utf8")) as StudioProjectState;
}
function writeStudioProject(projectRoot: string, state: StudioProjectState): void {
writeFileSync(path.join(projectRoot, ".codex", "studio.json"), `${JSON.stringify(state, null, 2)}\n`);
}
function workflowTitle(id: WorkflowId): string {
return id
.split("-")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function workflowBody(workflow: WorkflowId): string {
const definition = workflowRegistry[workflow];
const pkg = rolePackages[definition.role];
return [
`# ${workflowTitle(workflow)} Workflow`,
"",
"## Purpose",
"",
definition.objective,
"",
"## Inputs",
"",
...definition.contextFiles.map((file) => `- ${file}`),
"",
"## Role",
"",
`${pkg.displayName} (${definition.role}) owns this workflow.`,
"",
"## Outputs",
"",
...pkg.expectedOutputs.map((item) => `- ${item}`),
"",
"## Validation",
"",
...pkg.reviewChecklist.map((item) => `- ${item}`),
""
].join("\n");
}
function writeCodexWorkflowFiles(projectRoot: string): void {
const workflows = path.join(projectRoot, ".codex", "workflows");
mkdirSync(workflows, { recursive: true });
for (const workflow of workflowIds()) writeFileSync(path.join(projectRoot, workflowRegistry[workflow].file), workflowBody(workflow));
}
export function initProject(options: InitProjectOptions, cwd = process.cwd()): { projectRoot: string; config: ProjectConfig } {
const config = defaultProjectConfig(options);
const projectRoot = path.resolve(cwd, path.join("projects", config.project.slug));
@@ -108,7 +202,9 @@ export function initProject(options: InitProjectOptions, cwd = process.cwd()): {
mkdirSync(projectRoot, { recursive: true });
createEngineFolders({ projectRoot, projectSlug: config.project.slug, projectName: config.project.name, engine: config.project.engine, registry: engines });
createEngineProjectFiles({ projectRoot, projectSlug: config.project.slug, projectName: config.project.name, engine: config.project.engine, registry: engines, engineVersion: config.project.engine_version });
writeProjectConfig(path.join(projectRoot, "project-config.json"), config);
mkdirSync(path.join(projectRoot, ".codex", "runs"), { recursive: true });
writeStudioProject(projectRoot, studioStateFromConfig(config));
writeCodexWorkflowFiles(projectRoot);
writeStarterDocs(projectRoot, config);
materializeAgents({ projectRoot, config, engines });
return { projectRoot, config };
@@ -116,30 +212,29 @@ export function initProject(options: InitProjectOptions, cwd = process.cwd()): {
export function statusProject(project?: string, cwd = process.cwd()): string {
const root = resolveProjectRoot(project, cwd);
const config = readProjectConfig(path.join(root, "project-config.json"));
const config = readStudioProject(root);
return [
`${config.project.name}`,
`phase: ${config.project.phase}`,
`status: ${config.project.status}`,
`mode: ${config.project.mode}`,
`engine: ${config.project.engine}`,
`active agents: ${config.team.active_agents.join(", ")}`
`${config.name}`,
`phase: ${config.phase}`,
`status: ${config.status}`,
`mode: ${config.mode}`,
`engine: ${config.engine}`,
`active roles: ${(config.activeRoles ?? config.roles).join(", ")}`
].join("\n");
}
export function resumeProject(project?: string, cwd = process.cwd()): string {
const root = resolveProjectRoot(project, cwd);
const config = readProjectConfig(path.join(root, "project-config.json"));
return `Resume ${config.project.name}\nphase: ${config.project.phase}\nstatus: ${config.project.status}\nSuggested next command: npm exec open-gamestudio -- run producer_agent --project ${path.relative(cwd, root) || "."} --task "Summarize current project state"`;
const config = readStudioProject(root);
return `Resume ${config.name}\nphase: ${config.phase}\nstatus: ${config.status}\nSuggested next command: npm run build && node dist/cli.js run producer --project ${path.relative(cwd, root) || "."} "Summarize current project state"`;
}
export function freezeProject(project?: string, cwd = process.cwd()): string {
const root = resolveProjectRoot(project, cwd);
const file = path.join(root, "project-config.json");
const config = readProjectConfig(file);
config.project.status = "frozen";
writeProjectConfig(file, config);
return `Frozen ${config.project.name}`;
const config = readStudioProject(root);
config.status = "frozen";
writeStudioProject(root, config);
return `Frozen ${config.name}`;
}
export function expectedEngineProjectFile(projectRoot: string, config: ProjectConfig): string {
+197
View File
@@ -0,0 +1,197 @@
export const studioRoleIds = [
"studio-orchestrator",
"producer",
"market-analyst",
"data-scientist",
"creative-director",
"senior-game-designer",
"game-designer",
"narrative-designer",
"game-feel-designer",
"gameplay-programmer",
"engine-programmer",
"tools-programmer",
"senior-game-artist",
"technical-artist",
"ui-ux-designer",
"qa-playtester",
"release-manager"
] as const;
export type StudioRoleId = (typeof studioRoleIds)[number];
export type CodexRolePackage = {
id: StudioRoleId;
displayName: string;
systemPrompt: string;
contextStrategy: "minimal" | "focused" | "broad";
expectedOutputs: string[];
handoffTemplate: string;
reviewChecklist: string[];
};
function role(
id: StudioRoleId,
displayName: string,
systemPrompt: string,
contextStrategy: CodexRolePackage["contextStrategy"],
expectedOutputs: string[],
reviewChecklist: string[]
): CodexRolePackage {
return {
id,
displayName,
systemPrompt,
contextStrategy,
expectedOutputs,
handoffTemplate: `Report decisions, changed files or artifacts, verification results, next owner, and unresolved risks for ${displayName}.`,
reviewChecklist
};
}
export const rolePackages: Record<StudioRoleId, CodexRolePackage> = {
"studio-orchestrator": role(
"studio-orchestrator",
"Studio Orchestrator",
"Route work between roles, maintain concise handoffs, protect project scope, identify blockers, and select the next bounded studio action without running hidden parallel work.",
"broad",
["Studio handoff", "Next-role routing", "Blocker summary"],
["Next role and reason are explicit", "Scope and blockers are separated", "No hidden planner or parallel execution is implied"]
),
producer: role(
"producer",
"Producer",
"Convert goals into bounded production plans, milestone slices, risk lists, owner recommendations, and verification gates for Codex-executed game work.",
"focused",
["Production plan", "Milestone tasks", "Risk register"],
["Tasks are bounded", "Risks and gates are named", "Owners and verification are clear"]
),
"market-analyst": role(
"market-analyst",
"Market Analyst",
"Analyze audience, competitors, positioning, pricing, discoverability, and market risks using project constraints and clearly labeled assumptions.",
"focused",
["Market analysis", "Competitor positioning", "Audience risks"],
["Assumptions are explicit", "Competitors are tied to project constraints", "Recommendations are actionable"]
),
"data-scientist": role(
"data-scientist",
"Data Scientist",
"Define analytics events, success metrics, experiment plans, dashboards, and evidence loops that support design and production decisions.",
"focused",
["Analytics plan", "Event taxonomy", "Experiment outline"],
["Metrics map to decisions", "Events include trigger and properties", "Privacy and instrumentation risks are named"]
),
"creative-director": role(
"creative-director",
"Creative Director",
"Set creative pillars, protect player fantasy, align experience goals across disciplines, and keep scope coherent for Codex-executed game work.",
"focused",
["Creative direction", "Scope decisions", "Experience pillars"],
["Vision is concrete", "Scope tradeoffs are explicit", "Discipline guidance stays aligned"]
),
"senior-game-designer": role(
"senior-game-designer",
"Senior Game Designer",
"Own high-level systems, progression, economy, core loops, balancing direction, and design cohesion across feature slices.",
"focused",
["Systems design", "Progression model", "Acceptance criteria"],
["Rules and loops are coherent", "Economy and progression risks are covered", "Specs are implementable"]
),
"game-designer": role(
"game-designer",
"Game Designer",
"Design implementation-level mechanics, feature rules, tuning values, edge cases, and player-facing acceptance criteria with practical scope.",
"focused",
["Feature spec", "Tuning notes", "Acceptance criteria"],
["Rules are testable", "Edge cases are covered", "Implementation slices are bounded"]
),
"narrative-designer": role(
"narrative-designer",
"Narrative Designer",
"Shape story, tone, world rules, character and content needs, and narrative consistency while respecting production constraints.",
"minimal",
["Narrative brief", "Content list", "Tone guidance"],
["Tone is consistent", "Content is shippable", "Narrative constraints are explicit"]
),
"game-feel-designer": role(
"game-feel-designer",
"Game Feel Designer",
"Tune controls, feedback, pacing, animation timing, juice, camera response, and moment-to-moment player feel with actionable changes.",
"focused",
["Feel review", "Tuning recommendations", "Feedback checklist"],
["Controls are responsive", "Feedback supports player intent", "Tuning changes are actionable"]
),
"gameplay-programmer": role(
"gameplay-programmer",
"Gameplay Programmer",
"Implement gameplay systems with focused file edits, engine-native idioms, deterministic behavior, and verification evidence.",
"focused",
["Code changes", "Verification results", "Implementation notes"],
["Gameplay behavior matches spec", "Tests or manual checks are reported", "Engine idioms are respected"]
),
"engine-programmer": role(
"engine-programmer",
"Engine Programmer",
"Work on engine integration, performance-sensitive systems, build setup, runtime foundations, and platform constraints.",
"broad",
["Technical implementation", "Performance notes", "Build impact"],
["Engine constraints are respected", "Build impact is clear", "Performance risks are named"]
),
"tools-programmer": role(
"tools-programmer",
"Tools Programmer",
"Build editor, pipeline, automation, and developer workflow tools that reduce repeated production effort and failure-prone manual steps.",
"focused",
["Tooling changes", "Usage notes", "Failure modes"],
["Workflow is ergonomic", "Failure modes are handled", "Automation is scoped"]
),
"senior-game-artist": role(
"senior-game-artist",
"Senior Game Artist",
"Define art direction, asset style, visual constraints, production quality bars, reference needs, and asset review notes.",
"focused",
["Art direction", "Asset list", "Visual quality bar"],
["Style constraints are concrete", "Asset needs are prioritized", "Runtime and production limits are considered"]
),
"technical-artist": role(
"technical-artist",
"Technical Artist",
"Bridge art direction and runtime constraints for shaders, materials, import settings, optimization, and visual pipelines.",
"focused",
["Art pipeline guidance", "Asset constraints", "Optimization notes"],
["Asset edits are explicit", "Runtime cost is considered", "Pipeline risks are surfaced"]
),
"ui-ux-designer": role(
"ui-ux-designer",
"UI UX Designer",
"Design interface flows, usability heuristics, HUD layout, onboarding, accessibility, menu interactions, and interaction risks.",
"focused",
["UI flow review", "HUD/menu recommendations", "Accessibility notes"],
["Flows are understandable", "Accessibility is considered", "Interaction states are specified"]
),
"qa-playtester": role(
"qa-playtester",
"QA Playtester",
"Find reproducible gameplay, usability, accessibility, and regression issues with clear severity, evidence, and verification steps.",
"focused",
["Issue list", "Repro steps", "Severity notes"],
["Findings are reproducible", "Severity is calibrated", "Verification steps are clear"]
),
"release-manager": role(
"release-manager",
"Release Manager",
"Assess ship readiness, release risk, packaging, validation status, milestone blockers, and remaining warnings before release decisions.",
"broad",
["Ship checklist", "Release risks", "Validation summary"],
["Blockers are separated from warnings", "Validation is current", "Packaging risks are explicit"]
)
};
export function isStudioRoleId(value: string): value is StudioRoleId {
return (studioRoleIds as readonly string[]).includes(value);
}
export function unknownStudioRoleMessage(value: string): string {
return `Unknown studio role "${value}". Use Codex-native hyphenated role IDs such as producer, qa-playtester, or studio-orchestrator.`;
}
+241 -76
View File
@@ -1,21 +1,26 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
import path from "node:path";
import { agentNameSchema, readProjectConfig, type AgentName } from "./config.js";
import { readAgentPrompt } from "./agents.js";
import { loadEngineConfigs } from "./engines.js";
import { packageAssetPath, resolveProjectRoot } from "./paths.js";
import { readTemplate, selectTemplates } from "./templates.js";
import { buildCodexExecArgs, resolveCodexCommand, type CodexExecutionResult } from "./codex-runtime.js";
import { renderCodexPrompt } from "./codex-prompts.js";
import { createCodexStudioSession, type VerificationCommand } from "./codex-session.js";
import { readStudioProject } from "./projects.js";
import { isStudioRoleId, unknownStudioRoleMessage, type StudioRoleId } from "./roles.js";
import { resolveProjectRoot } from "./paths.js";
import { runVerificationCommand, type VerificationResult } from "./verification.js";
export type RunOptions = {
project: string;
task: string;
printPrompt?: boolean;
dryRun?: boolean;
exec?: boolean;
codexBin?: string;
includeArtifact?: string[];
allowBroadContext?: boolean;
verifyCommand?: VerificationCommand;
review?: boolean;
fix?: boolean;
maxFixPasses?: number;
};
export type PreparedRun = {
@@ -23,35 +28,63 @@ export type PreparedRun = {
promptPath: string;
metadataPath: string;
projectRoot: string;
role: StudioRoleId;
task: string;
contextFiles: string[];
verification?: VerificationCommand;
codexCommand: { command: string; args: string[]; display: string };
output: string;
reviewPrompt?: string;
fixPrompt?: string;
maxFixPasses: number;
};
export type ReviewResult = {
blockers: string[];
warnings: string[];
summary: string;
needsFix: boolean;
};
export type ReviewPassResult = {
execution: CodexExecutionResult;
raw: string;
parsed?: ReviewResult;
malformed?: string;
};
export type FixPassResult = {
pass: number;
execution: CodexExecutionResult;
verification?: VerificationResult;
review?: ReviewPassResult;
};
export type RunLifecycleResult = {
implementation: CodexExecutionResult;
verification?: VerificationResult;
review?: ReviewPassResult;
fixPasses: FixPassResult[];
finalStatus: "done" | "blocked";
output: string;
};
export type CodexExecutionResult = {
status: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
error?: Error;
};
function requireTask(task: string): string {
if (!task || !task.trim()) throw new Error("--task is required and must be non-empty");
if (!task || !task.trim()) throw new Error("task is required and must be non-empty");
return task.trim();
}
export function codexExecInvocation(projectRoot: string, promptPath: string, codexBin = "codex"): { command: string; args: string[]; display: string } {
const promptRelative = path.relative(projectRoot, promptPath);
const taskPrompt = `Read ${promptRelative} and perform the requested task.`;
const args = ["exec", "--cd", projectRoot, taskPrompt];
export function codexExecInvocation(projectRoot: string, codexBin = resolveCodexCommand()): { command: string; args: string[]; display: string } {
const args = buildCodexExecArgs({ projectRoot, sandbox: "workspace-write" });
return { command: codexBin, args, display: [codexBin, ...args.map((arg) => JSON.stringify(arg))].join(" ") };
}
export function executeCodexRun(run: PreparedRun): CodexExecutionResult {
export function executeCodexPromptSync(run: PreparedRun, prompt: string): CodexExecutionResult {
const result = spawnSync(run.codexCommand.command, run.codexCommand.args, {
cwd: run.projectRoot,
encoding: "utf8"
encoding: "utf8",
input: prompt,
shell: false
});
return {
status: result.status,
@@ -62,6 +95,10 @@ export function executeCodexRun(run: PreparedRun): CodexExecutionResult {
};
}
export function executeCodexRun(run: PreparedRun): CodexExecutionResult {
return executeCodexPromptSync(run, run.prompt);
}
function safeArtifact(projectRoot: string, artifact: string): string {
if (path.isAbsolute(artifact)) throw new Error("--include-artifact must be relative");
const full = path.resolve(projectRoot, artifact);
@@ -72,62 +109,184 @@ function safeArtifact(projectRoot: string, artifact: string): string {
return full;
}
function executionFailed(result: CodexExecutionResult): boolean {
return Boolean(result.error) || result.status !== 0;
}
function verificationFailed(result: VerificationResult): boolean {
return result.timedOut || result.exitCode !== 0;
}
export function parseReviewJson(raw: string): ReviewResult {
const trimmed = raw.trim();
const start = trimmed.indexOf("{");
const end = trimmed.lastIndexOf("}");
if (start === -1 || end === -1 || end < start) throw new Error("review output did not contain a JSON object");
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as Partial<ReviewResult>;
if (!Array.isArray(parsed.blockers) || !parsed.blockers.every((item) => typeof item === "string")) throw new Error("review JSON blockers must be string[]");
if (!Array.isArray(parsed.warnings) || !parsed.warnings.every((item) => typeof item === "string")) throw new Error("review JSON warnings must be string[]");
if (typeof parsed.summary !== "string") throw new Error("review JSON summary must be a string");
if (typeof parsed.needsFix !== "boolean") throw new Error("review JSON needsFix must be boolean");
return { blockers: parsed.blockers, warnings: parsed.warnings, summary: parsed.summary, needsFix: parsed.needsFix };
}
function runReviewPass(run: PreparedRun, previousSummary = ""): ReviewPassResult | undefined {
if (!run.reviewPrompt) return undefined;
const prompt = `${run.reviewPrompt}\n\n# Implementation and verification output\n\n${previousSummary}\n`;
const execution = executeCodexPromptSync(run, prompt);
const raw = execution.stdout.trim() || execution.stderr.trim();
if (executionFailed(execution)) return { execution, raw, malformed: execution.error?.message ?? `review exited with status ${execution.status}` };
try {
return { execution, raw, parsed: parseReviewJson(raw) };
} catch (error) {
return { execution, raw, malformed: (error as Error).message };
}
}
function reviewFailed(review: ReviewPassResult | undefined): boolean {
if (!review) return false;
if (review.malformed) return true;
return Boolean(review.parsed?.needsFix || review.parsed?.blockers.length);
}
function formatCodex(label: string, result: CodexExecutionResult): string[] {
const lines = [`${label}: exit=${result.status ?? "null"}${result.signal ? ` signal=${result.signal}` : ""}${result.error ? ` error=${result.error.message}` : ""}`];
if (result.stdout.trim()) lines.push(`${label} stdout:\n${result.stdout.trim()}`);
if (result.stderr.trim()) lines.push(`${label} stderr:\n${result.stderr.trim()}`);
return lines;
}
function formatVerification(result: VerificationResult): string[] {
const lines = [`verification: ${result.command} ${result.args.join(" ")} exit=${result.exitCode ?? "null"}${result.signal ? ` signal=${result.signal}` : ""}${result.timedOut ? " timedOut=true" : ""}`];
if (result.stdout.trim()) lines.push(`verification stdout:\n${result.stdout.trim()}`);
if (result.stderr.trim()) lines.push(`verification stderr:\n${result.stderr.trim()}`);
return lines;
}
function formatReview(review: ReviewPassResult | undefined): string[] {
if (!review) return [];
const lines = formatCodex("review", review.execution);
if (review.malformed) lines.push(`review blocked: malformed review JSON (${review.malformed})`);
if (review.parsed) {
lines.push(`review summary: ${review.parsed.summary}`);
lines.push(`review needsFix: ${review.parsed.needsFix}`);
if (review.parsed.blockers.length) lines.push(`review blockers:\n${review.parsed.blockers.map((b) => `- ${b}`).join("\n")}`);
if (review.parsed.warnings.length) lines.push(`review warnings:\n${review.parsed.warnings.map((w) => `- ${w}`).join("\n")}`);
}
return lines;
}
async function runVerificationIfConfigured(run: PreparedRun): Promise<VerificationResult | undefined> {
if (!run.verification) return undefined;
return await runVerificationCommand(run.verification, { cwd: run.projectRoot });
}
function blockedAfter(implementation: CodexExecutionResult, verification?: VerificationResult, review?: ReviewPassResult): boolean {
return executionFailed(implementation) || Boolean(verification && verificationFailed(verification)) || reviewFailed(review);
}
export async function executeRunLifecycle(run: PreparedRun): Promise<RunLifecycleResult> {
const output: string[] = [];
const implementation = executeCodexRun(run);
output.push(...formatCodex("implementation", implementation));
let verification: VerificationResult | undefined;
let review: ReviewPassResult | undefined;
if (!executionFailed(implementation)) {
verification = await runVerificationIfConfigured(run);
if (verification) output.push(...formatVerification(verification));
if (!verification || !verificationFailed(verification)) {
review = runReviewPass(run, output.join("\n\n"));
output.push(...formatReview(review));
}
}
const fixPasses: FixPassResult[] = [];
let blocked = blockedAfter(implementation, verification, review);
while (blocked && run.fixPrompt && fixPasses.length < run.maxFixPasses) {
const pass = fixPasses.length + 1;
const fixExecution = executeCodexPromptSync(run, `${run.fixPrompt}\n\n# Previous run summary\n\n${output.join("\n\n")}\n`);
output.push(...formatCodex(`fix pass ${pass}`, fixExecution));
let fixVerification: VerificationResult | undefined;
let fixReview: ReviewPassResult | undefined;
if (!executionFailed(fixExecution)) {
fixVerification = await runVerificationIfConfigured(run);
if (fixVerification) output.push(...formatVerification(fixVerification));
if (!fixVerification || !verificationFailed(fixVerification)) {
fixReview = runReviewPass(run, output.join("\n\n"));
output.push(...formatReview(fixReview));
}
}
fixPasses.push({ pass, execution: fixExecution, verification: fixVerification, review: fixReview });
blocked = executionFailed(fixExecution) || Boolean(fixVerification && verificationFailed(fixVerification)) || reviewFailed(fixReview);
}
if (blocked && run.fixPrompt && fixPasses.length >= run.maxFixPasses) output.push(`blocked: reached max fix passes (${run.maxFixPasses})`);
const finalStatus: "done" | "blocked" = blocked ? "blocked" : "done";
output.push(`final status: ${finalStatus}`);
return { implementation, verification, review, fixPasses, finalStatus, output: output.join("\n\n") };
}
let runSequence = 0;
export function prepareRun(agentInput: string, options: RunOptions, cwd = process.cwd()): PreparedRun {
if (options.exec && (options.printPrompt || options.dryRun)) throw new Error("--exec cannot be combined with --print-prompt or --dry-run");
const agent = agentNameSchema.parse(agentInput) as AgentName;
export function prepareRun(roleInput: string, options: RunOptions, cwd = process.cwd()): PreparedRun {
if (!isStudioRoleId(roleInput)) throw new Error(unknownStudioRoleMessage(roleInput));
const role = roleInput as StudioRoleId;
const task = requireTask(options.task);
const projectRoot = resolveProjectRoot(options.project, cwd);
const config = readProjectConfig(path.join(projectRoot, "project-config.json"));
const engines = loadEngineConfigs(packageAssetPath("engine_configs"));
const engine = engines[config.project.engine];
const templates = selectTemplates(agent, task);
const contextFiles = [
path.relative(projectRoot, path.join(projectRoot, ".gamestudio", "agents", `${agent}.md`)),
"project-config.json",
`engine_configs/${config.project.engine}.json`,
...templates.map((id) => `templates/${id}`)
];
const studio = readStudioProject(projectRoot);
const contextFiles = ["AGENTS.md", ".codex/studio.json", `.codex/prompts/${role}.md`];
const artifactBodies = (options.includeArtifact ?? []).map((artifact) => {
const full = safeArtifact(projectRoot, artifact);
contextFiles.push(artifact);
return `# Included Artifact: ${artifact}\n\n${readFileSync(full, "utf8")}`;
return `\n\n# Included Artifact: ${artifact}\n\n${readFileSync(full, "utf8")}`;
});
const templateBodies = templates.map((id) => `# Template: ${id}\n\n${readTemplate(id)}`).join("\n\n");
const outputPaths = [
agent === "market_analyst" ? "resources/market-research/market-analysis.md" : undefined,
agent === "data_scientist" ? "documentation/technical/analytics/analytics-plan.md" : undefined,
agent === "qa_agent" ? "documentation/qa/validation-review.md" : undefined
].filter(Boolean);
const prompt = [
`# Open GameStudio Prompt`,
`Agent: ${agent}`,
`Task: ${task}`,
`Project: ${config.project.name} (${config.project.slug})`,
`Engine: ${engine.display_name} ${config.project.engine_version}`,
`Validation: npm run validate -- --project ${path.relative(cwd, projectRoot) || "."}`,
"",
"# Agent Prompt",
readAgentPrompt(agent, projectRoot),
"",
"# Project Summary",
`Concept: ${config.project.concept}`,
`Audience: ${config.project.audience}`,
`Competitors: ${config.project.competitors.join(", ")}`,
"",
"# Engine Overlay",
Object.values(engine.agent_specializations).join("\n"),
"",
templateBodies,
...artifactBodies,
"",
"# Output Paths",
outputPaths.length ? outputPaths.map((p) => `- ${p}`).join("\n") : "- Use the role prompt output path conventions.",
options.allowBroadContext ? "\n# Broad Context\nExplicit broad context opt-in was provided." : ""
].join("\n");
if (options.allowBroadContext) contextFiles.push("Broad context explicitly allowed by CLI flag.");
const maxFixPasses = options.maxFixPasses ?? 1;
if (!Number.isFinite(maxFixPasses) || maxFixPasses < 0) throw new Error("--max-fix-passes must be a finite non-negative number");
const session = createCodexStudioSession({
projectRoot,
role,
objective: task,
phase: role === "producer" ? "plan" : role === "qa-playtester" ? "review" : "implement",
engine: studio.engine,
contextFiles,
verification: options.verifyCommand
});
const prompt = `${renderCodexPrompt(session)}${artifactBodies.join("")}`;
const reviewPrompt = options.review
? renderCodexPrompt(
createCodexStudioSession({
projectRoot,
role: "qa-playtester",
objective: `Review the implementation for: ${task}. Inspect the diff and verification output. Return only JSON with blockers, warnings, summary, and needsFix.`,
phase: "review",
engine: studio.engine,
contextFiles: ["AGENTS.md", ".codex/studio.json"],
expectedOutputs: ["Review JSON", "Blockers", "Warnings"],
allowFileEdits: false,
sandbox: "read-only",
reviewMode: "diff"
})
)
: undefined;
const fixPrompt =
options.fix && maxFixPasses > 0
? renderCodexPrompt(
createCodexStudioSession({
projectRoot,
role,
objective: `Fix verification failures or review blockers for: ${task}`,
phase: "fix",
engine: studio.engine,
contextFiles,
verification: options.verifyCommand,
expectedOutputs: ["Fix changes", "Verification results", "Remaining blockers"]
})
)
: undefined;
const runId = `${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 17)}-${process.pid}-${++runSequence}`;
const runDir = path.join(projectRoot, ".gamestudio", "runs", `${runId}-${agent}`);
const runDir = path.join(projectRoot, ".codex", "runs", `${runId}-${role}`);
mkdirSync(runDir, { recursive: true });
const promptPath = path.join(runDir, "prompt.md");
const metadataPath = path.join(runDir, "metadata.json");
@@ -137,23 +296,29 @@ export function prepareRun(agentInput: string, options: RunOptions, cwd = proces
`${JSON.stringify(
{
timestamp: new Date().toISOString(),
product: "codex-game-studio",
project: path.relative(cwd, projectRoot) || ".",
agent,
role,
task,
prompt_chars: prompt.length,
prompt_cache_path: path.relative(cwd, promptPath)
prompt_cache_path: path.relative(cwd, promptPath),
review: Boolean(reviewPrompt),
fix: Boolean(fixPrompt),
max_fix_passes: maxFixPasses
},
null,
2
)}\n`
);
const codexCommand = codexExecInvocation(projectRoot, promptPath, options.codexBin ?? process.env.OPEN_GAMESTUDIO_CODEX_BIN ?? "codex");
const codexCommand = codexExecInvocation(projectRoot, options.codexBin ?? resolveCodexCommand());
const dryRunExtra = [
reviewPrompt ? `\n\nReview prompt:\n${reviewPrompt}\n\nExpected review JSON schema: {"blockers":[],"warnings":[],"summary":"","needsFix":false}` : "",
fixPrompt ? `\n\nFix prompt (max passes: ${maxFixPasses}):\n${fixPrompt}` : ""
].join("");
const output = options.printPrompt
? prompt
: options.dryRun
? `Prompt cache: ${promptPath}\nMetadata: ${metadataPath}\nContext files:\n${contextFiles.map((f) => `- ${f}`).join("\n")}\nValidation: npm run validate -- --project ${path.relative(cwd, projectRoot) || "."}`
: options.exec
? `Prompt cache written: ${promptPath}\nExecuting Codex: ${codexCommand.display}`
: `Prompt cache written: ${promptPath}\nNext Codex command: ${codexCommand.display}`;
return { prompt, promptPath, metadataPath, projectRoot, contextFiles, codexCommand, output };
? `Prompt cache: ${promptPath}\nMetadata: ${metadataPath}\nContext files:\n${contextFiles.map((f) => `- ${f}`).join("\n")}\nCodex command: ${codexCommand.display}${dryRunExtra}`
: `Prompt cache written: ${promptPath}\nExecuting Codex: ${codexCommand.display}`;
return { prompt, promptPath, metadataPath, projectRoot, role, task, contextFiles, verification: options.verifyCommand, codexCommand, output, reviewPrompt, fixPrompt, maxFixPasses };
}
+166
View File
@@ -0,0 +1,166 @@
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { createCodexStudioSession, type VerificationCommand } from "./codex-session.js";
import { renderCodexPrompt } from "./codex-prompts.js";
import { resolveProjectRoot } from "./paths.js";
import { readStudioProject } from "./projects.js";
import { isStudioRoleId, type StudioRoleId } from "./roles.js";
import { executeRunLifecycle, prepareRun, type PreparedRun, type RunLifecycleResult } from "./runner.js";
export type StudioTaskStatus = "ready" | "running" | "blocked" | "done";
export type StudioTask = {
id: string;
title: string;
role: StudioRoleId;
status: StudioTaskStatus;
files: string[];
verification?: VerificationCommand;
notes: string[];
};
export type TaskStore = {
schemaVersion: 1;
tasks: StudioTask[];
};
export type ExecuteTaskRunOptions = {
dryRun?: boolean;
codexBin?: string;
review?: boolean;
fix?: boolean;
maxFixPasses?: number;
};
export type ExecuteTaskRunResult = {
task: StudioTask;
prepared: PreparedRun;
lifecycle?: RunLifecycleResult;
};
export function taskStorePath(projectRoot: string): string {
return path.join(projectRoot, ".codex", "tasks.json");
}
export function readTaskStore(projectRoot: string): TaskStore {
const file = taskStorePath(projectRoot);
if (!existsSync(file)) return { schemaVersion: 1, tasks: [] };
let parsed: TaskStore;
try {
parsed = JSON.parse(readFileSync(file, "utf8")) as TaskStore;
} catch (error) {
throw new Error(`Invalid task store JSON: ${(error as Error).message}`);
}
if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.tasks)) throw new Error("Invalid task store schema");
const ids = new Set<string>();
for (const task of parsed.tasks) {
if (ids.has(task.id)) throw new Error(`Duplicate task id: ${task.id}`);
ids.add(task.id);
if (!isStudioRoleId(task.role)) throw new Error(`Invalid task role: ${task.role}`);
if (!["ready", "running", "blocked", "done"].includes(task.status)) throw new Error(`Invalid task status: ${task.status}`);
if (!Array.isArray(task.files) || !Array.isArray(task.notes)) throw new Error(`Invalid task shape: ${task.id}`);
}
return parsed;
}
export function writeTaskStore(projectRoot: string, store: TaskStore): void {
const dir = path.join(projectRoot, ".codex");
mkdirSync(dir, { recursive: true });
const tmp = path.join(dir, `tasks.${process.pid}.${Date.now()}.tmp`);
try {
writeFileSync(tmp, `${JSON.stringify(store, null, 2)}\n`);
renameSync(tmp, taskStorePath(projectRoot));
} catch (error) {
rmSync(tmp, { force: true });
throw error;
}
}
function nextTaskId(tasks: StudioTask[]): string {
const max = tasks.reduce((found, task) => {
const match = /^task-(\d+)$/.exec(task.id);
return match ? Math.max(found, Number(match[1])) : found;
}, 0);
return `task-${String(max + 1).padStart(3, "0")}`;
}
function getTask(store: TaskStore, taskId: string): StudioTask {
const task = store.tasks.find((candidate) => candidate.id === taskId);
if (!task) throw new Error(`Unknown task ${taskId}`);
return task;
}
export function createTask(projectRoot: string, input: { title: string; role: StudioRoleId; verification?: VerificationCommand; files?: string[] }): StudioTask {
if (!input.title.trim()) throw new Error("task title is required");
const store = readTaskStore(projectRoot);
const task: StudioTask = {
id: nextTaskId(store.tasks),
title: input.title.trim(),
role: input.role,
status: "ready",
files: input.files ?? [],
verification: input.verification,
notes: []
};
writeTaskStore(projectRoot, { schemaVersion: 1, tasks: [...store.tasks, task] });
return task;
}
export function updateTaskStatus(projectRoot: string, taskId: string, status: StudioTaskStatus, note?: string): StudioTask {
const store = readTaskStore(projectRoot);
const task = getTask(store, taskId);
task.status = status;
if (note?.trim()) task.notes.push(`${new Date().toISOString()} ${note.trim()}`);
writeTaskStore(projectRoot, store);
return task;
}
export function renderTaskRun(projectRoot: string, taskId: string): { task: StudioTask; prompt: string } {
const studio = readStudioProject(projectRoot);
const task = getTask(readTaskStore(projectRoot), taskId);
const session = createCodexStudioSession({
projectRoot,
role: task.role,
objective: task.title,
phase: "implement",
engine: studio.engine,
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/tasks.json", `.codex/prompts/${task.role}.md`, ...task.files],
verification: task.verification
});
return { task, prompt: renderCodexPrompt(session) };
}
export async function executeTaskRun(projectRoot: string, taskId: string, options: ExecuteTaskRunOptions = {}): Promise<ExecuteTaskRunResult> {
const task = getTask(readTaskStore(projectRoot), taskId);
const prepared = prepareRun(
task.role,
{
project: projectRoot,
task: task.title,
dryRun: options.dryRun,
codexBin: options.codexBin,
includeArtifact: task.files,
verifyCommand: task.verification,
review: options.review,
fix: options.fix,
maxFixPasses: options.maxFixPasses
},
process.cwd()
);
if (options.dryRun) return { task, prepared };
updateTaskStatus(projectRoot, taskId, "running", "Codex task run started");
try {
const lifecycle = await executeRunLifecycle(prepared);
const finalStatus: StudioTaskStatus = lifecycle.finalStatus === "done" ? "done" : "blocked";
const updated = updateTaskStatus(projectRoot, taskId, finalStatus, `Codex task run finished: ${lifecycle.finalStatus}`);
return { task: updated, prepared, lifecycle };
} catch (error) {
const updated = updateTaskStatus(projectRoot, taskId, "blocked", `Codex task run failed uncertainly: ${(error as Error).message}`);
throw Object.assign(error as Error, { task: updated });
}
}
export function resolveTaskProject(project: string | undefined, cwd = process.cwd()): string {
return resolveProjectRoot(project, cwd);
}
+11 -11
View File
@@ -26,7 +26,7 @@ export const templateRegistry: Record<TemplateId, TemplateInfo> = {
id: "gdd",
category: "design",
path: "templates/gdd_template.md",
roles: ["sr_game_designer", "mid_game_designer"],
roles: ["game-designer", "creative-director"],
tags: ["design", "gdd"],
requiredSections: ["# Purpose", "# Inputs", "# Outputs", "# Validation"]
},
@@ -34,7 +34,7 @@ export const templateRegistry: Record<TemplateId, TemplateInfo> = {
id: "feature_spec",
category: "design",
path: "templates/feature_spec_template.md",
roles: ["sr_game_designer", "mid_game_designer", "mechanics_developer"],
roles: ["senior-game-designer", "game-designer", "gameplay-programmer"],
tags: ["feature", "spec", "design"],
requiredSections: ["# Purpose", "# Inputs", "# Outputs", "# Validation"]
},
@@ -42,7 +42,7 @@ export const templateRegistry: Record<TemplateId, TemplateInfo> = {
id: "handoff",
category: "coordination",
path: "templates/handoff_template.md",
roles: ["master_orchestrator", "producer_agent"],
roles: ["studio-orchestrator", "creative-director", "producer"],
tags: ["handoff", "coordination"],
requiredSections: ["# Purpose", "# Inputs", "# Outputs", "# Validation"]
},
@@ -50,7 +50,7 @@ export const templateRegistry: Record<TemplateId, TemplateInfo> = {
id: "analytics_setup",
category: "analytics",
path: "templates/analytics_setup_template.md",
roles: ["data_scientist"],
roles: ["data-scientist", "producer"],
tags: ["analytics", "metrics"],
requiredSections: ["# Purpose", "# Inputs", "# Outputs", "# Validation"]
},
@@ -58,7 +58,7 @@ export const templateRegistry: Record<TemplateId, TemplateInfo> = {
id: "engine_setup",
category: "engine",
path: "templates/engine_setup_template.md",
roles: ["mechanics_developer", "technical_artist"],
roles: ["gameplay-programmer", "engine-programmer", "technical-artist"],
tags: ["engine", "setup"],
requiredSections: ["# Purpose", "# Inputs", "# Outputs", "# Validation"]
},
@@ -66,7 +66,7 @@ export const templateRegistry: Record<TemplateId, TemplateInfo> = {
id: "market_analysis",
category: "market",
path: "templates/market_analysis_template.md",
roles: ["market_analyst"],
roles: ["market-analyst", "producer"],
tags: ["market", "competitors"],
requiredSections: ["# Purpose", "# Inputs", "# Outputs", "# Validation"]
},
@@ -74,7 +74,7 @@ export const templateRegistry: Record<TemplateId, TemplateInfo> = {
id: "project_config",
category: "config",
path: "templates/project_config_template.json",
roles: ["producer_agent", "master_orchestrator"],
roles: ["producer", "creative-director"],
tags: ["config", "setup"],
requiredSections: []
}
@@ -140,9 +140,9 @@ export function selectTemplates(agent: AgentName, task: string): TemplateId[] {
const lower = task.toLowerCase();
const selected = new Set<TemplateId>();
if (/(handoff|coordination|coordinate)/.test(lower)) selected.add("handoff");
if (agent === "market_analyst") selected.add("market_analysis");
if (agent === "data_scientist") selected.add("analytics_setup");
if ((agent === "sr_game_designer" || agent === "mid_game_designer") && /(design|spec|gdd|feature)/.test(lower)) {
if ((agent === "producer" || agent === "market-analyst") && /(market|competitor|audience)/.test(lower)) selected.add("market_analysis");
if ((agent === "producer" || agent === "data-scientist") && /(analytics|metric|telemetry)/.test(lower)) selected.add("analytics_setup");
if ((agent === "senior-game-designer" || agent === "game-designer" || agent === "gameplay-programmer") && /(design|spec|gdd|feature)/.test(lower)) {
selected.add("gdd");
selected.add("feature_spec");
}
@@ -150,6 +150,6 @@ export function selectTemplates(agent: AgentName, task: string): TemplateId[] {
selected.add("engine_setup");
selected.add("project_config");
}
if (agent === "qa_agent" && /(spec review|review spec)/.test(lower)) selected.add("feature_spec");
if (agent === "qa-playtester" && /(spec review|review spec)/.test(lower)) selected.add("feature_spec");
return [...selected];
}
+142 -84
View File
@@ -2,12 +2,17 @@ import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { agentNames, activeAgentsForMode, guidanceConfigHash, readProjectConfig } from "./config.js";
import { validateBaseAgents } from "./agents.js";
import { loadEngineConfigs, normalizeEngine, sourceRoot } from "./engines.js";
import { activeAgentsForMode } from "./config.js";
import { projectAgentsMdRequiredSections, validateBaseAgents } from "./agents.js";
import { checkCodexAvailability } from "./codex-runtime.js";
import { createCodexStudioSession } from "./codex-session.js";
import { renderCodexPrompt } from "./codex-prompts.js";
import { loadEngineConfigs, sourceRoot, unrealProjectFileName } from "./engines.js";
import { packageAssetPath } from "./paths.js";
import { expectedEngineProjectFile, resumeProject, statusProject } from "./projects.js";
import { readStudioProject, resumeProject, statusProject } from "./projects.js";
import { rolePackages, studioRoleIds } from "./roles.js";
import { templateRegistry, validateTemplateFiles } from "./templates.js";
import { renderWorkflowPrompt, workflowIds, workflowRegistry } from "./workflows.js";
export type CheckStatus = "pass" | "fail" | "skip";
export type ValidationCheck = { id: string; status: CheckStatus; message: string; path?: string };
@@ -27,67 +32,118 @@ function sectionHasContent(body: string, section: string): boolean {
const start = match.index + match[0].length;
const rest = body.slice(start);
const nextHeading = rest.search(/^#/m);
const content = nextHeading === -1 ? rest : rest.slice(0, nextHeading);
return content.trim().length > 0;
return (nextHeading === -1 ? rest : rest.slice(0, nextHeading)).trim().length > 0;
}
const requiredAgentSections = ["# Role", "# Inputs", "# Outputs", "# Validation", "# Engine Notes", "# Rules"];
function stablePromptSectionChecks(projectRoot: string, role: (typeof studioRoleIds)[number], projectName: string): ValidationCheck[] {
const file = path.join(projectRoot, ".codex", "prompts", `${role}.md`);
if (!existsSync(file)) return [fail(`codex.role.${role}.prompt.exists`, `${role} prompt missing`, file), fail(`codex.prompt.${role}`, `${role} prompt missing`, file)];
const body = readFileSync(file, "utf8");
const checks = [pass(`codex.role.${role}.prompt.exists`, `${role} prompt exists`, file)];
checks.push(body.trim().length > 0 ? pass(`codex.prompt.${role}`, `${role} prompt exists`, file) : fail(`codex.prompt.${role}`, `${role} prompt missing`, file));
const projectLine = `Project: ${projectName}`;
checks.push(body.includes(projectLine) ? pass(`codex.role.${role}.prompt.project`, `${projectLine} exists`, file) : fail(`codex.role.${role}.prompt.project`, `${projectLine} missing`, file));
const roleLine = `Role: ${rolePackages[role].displayName}`;
checks.push(body.includes(roleLine) ? pass(`codex.role.${role}.prompt.role`, `${roleLine} exists`, file) : fail(`codex.role.${role}.prompt.role`, `${roleLine} missing`, file));
for (const [id, label] of [
["project-summary", "## Project Summary"],
["engine-context", "## Engine Context"],
["role-instructions", "## Role Instructions"],
["expected-outputs", "## Expected Outputs"],
["review-checklist", "## Review Checklist"],
["handoff", "## Handoff"]
] as const) {
checks.push(sectionHasContent(body, label) ? pass(`codex.role.${role}.prompt.${id}`, `${label} exists`, file) : fail(`codex.role.${role}.prompt.${id}`, `${label} missing content`, file));
}
return checks;
}
export async function validateRepo(root = process.cwd()): Promise<ValidationCheck[]> {
const checks: ValidationCheck[] = [];
const pkgPath = path.join(root, "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
scripts?: Record<string, string>;
bin?: Record<string, string>;
files?: string[];
engines?: { node?: string };
};
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { scripts?: Record<string, string>; bin?: Record<string, string>; files?: string[]; engines?: { node?: string } };
const scripts = pkg.scripts ?? {};
for (const script of ["init", "manage", "test", "validate", "templates"]) {
checks.push(scripts[script] ? pass(`package.script.${script}`, `script ${script} exists`) : fail(`package.script.${script}`, `missing script ${script}`, pkgPath));
checks.push(scripts[script] ? pass(`pkg.script.${script}`, `script ${script} exists`) : fail(`pkg.script.${script}`, `missing script ${script}`, pkgPath));
}
checks.push(scripts.build === "tsc -p tsconfig.build.json" ? pass("package.build", "build uses tsconfig.build.json") : fail("package.build", "build must use tsconfig.build.json", pkgPath));
checks.push(pkg.bin?.["open-gamestudio"] === "./dist/cli.js" ? pass("package.bin", "bin points to dist/cli.js") : fail("package.bin", "bin must point to ./dist/cli.js", pkgPath));
checks.push(pkg.engines?.node?.includes(">=20") ? pass("package.node", "node floor declared") : fail("package.node", "node >=20 must be declared", pkgPath));
for (const file of ["dist/", "engine_configs/", "agents/base/", "templates/"]) {
checks.push(pkg.files?.includes(file) ? pass(`package.files.${file}`, `${file} shipped`) : fail(`package.files.${file}`, `${file} missing from package files`, pkgPath));
for (const file of ["dist/", "engine_configs/", "templates/"]) {
checks.push(pkg.files?.includes(file) ? pass(`pkg.files.${file}`, `${file} shipped`) : fail(`pkg.files.${file}`, `${file} missing from package files`, pkgPath));
}
for (const file of ["src/cli.ts", "src/paths.ts", "src/config.ts", "src/engines.ts", "src/templates.ts", "src/agents.ts", "src/projects.ts", "src/runner.ts", "src/validation.ts"]) {
checks.push(existsSync(path.join(root, file)) ? pass(`source.${file}`, `${file} exists`) : fail(`source.${file}`, `${file} missing`, file));
for (const file of ["src/cli.ts", "src/codex-runtime.ts", "src/codex-session.ts", "src/codex-prompts.ts", "src/roles.ts", "src/tasks.ts", "src/workflows.ts", "src/verification.ts", "src/projects.ts", "src/runner.ts", "src/validation.ts"]) {
checks.push(existsSync(path.join(root, file)) ? pass(`src.${file}`, `${file} exists`) : fail(`src.${file}`, `${file} missing`, file));
}
const tsFiles = ["cli", "config", "engines", "templates", "agents", "projects", "runner", "validation", "paths"].map((f) => path.join(root, "src", `${f}.ts`));
for (const file of tsFiles.filter(existsSync)) {
const body = readFileSync(file, "utf8");
const bad = body.match(/from "\.\/(?!.*\.js")/);
if (bad) checks.push(fail("typescript.imports", `relative import missing .js in ${file}`, file));
const codex = await checkCodexAvailability();
checks.push(codex.ok ? pass("codex.cli", "Codex CLI is available") : fail("codex.cli", codex.reason ?? "Codex CLI unavailable or unauthenticated"));
const baseAgentFailures = validateBaseAgents();
checks.push(...baseAgentFailures.map((message) => fail("codex.roles", message)));
for (const role of studioRoleIds) {
const rendered = renderCodexPrompt(createCodexStudioSession({ projectRoot: root, role, objective: "validate role rendering", phase: "plan" }));
checks.push(rendered.includes(rolePackages[role].displayName) ? pass(`codex.role.${role}.package-render`, `${role} package renders`) : fail(`codex.role.${role}.package-render`, `${role} package did not render`));
}
const engines = loadEngineConfigs(packageAssetPath("engine_configs"));
for (const value of ["Godot", "Unity", "Unreal", "Unreal Engine", "ue5"]) {
try {
normalizeEngine(value, engines);
checks.push(pass(`engine.alias.${value}`, `${value} normalizes`));
} catch (error) {
checks.push(fail(`engine.alias.${value}`, (error as Error).message));
}
for (const [id, config] of Object.entries(engines)) {
checks.push(config.codex_hints.length && config.run_command && config.test_command ? pass(`codex.engine.${id}`, `${id} Codex hints parse`) : fail(`codex.engine.${id}`, `${id} Codex hints missing`));
}
checks.push(...validateBaseAgents().map((message) => fail("agents.base", message)));
if (validateBaseAgents().length === 0) checks.push(pass("agents.base", "all 12 base agents exist"));
for (const workflow of workflowIds()) {
const definition = workflowRegistry[workflow];
checks.push(definition.file.endsWith(`${workflow}.md`) ? pass(`codex.workflow.${workflow}.registry`, `${workflow} registry entry exists`) : fail(`codex.workflow.${workflow}.registry`, `${workflow} registry file mismatch`));
const rendered = renderCodexPrompt(createCodexStudioSession({ projectRoot: root, role: definition.role, objective: definition.objective, phase: definition.phase, contextFiles: definition.contextFiles }));
checks.push(rendered.includes(definition.objective) ? pass(`codex.workflow.${workflow}.render`, `${workflow} workflow renders`) : fail(`codex.workflow.${workflow}.render`, `${workflow} workflow did not render`));
}
const templateFailures = validateTemplateFiles();
checks.push(...templateFailures.map((message) => fail("templates", message)));
for (const [id, info] of Object.entries(templateRegistry)) {
const file = packageAssetPath(info.path);
if (!existsSync(file)) {
checks.push(fail(`codex.template.${id}.exists`, `${id} template missing`, file));
continue;
}
const body = readFileSync(file, "utf8");
const missingSection = info.requiredSections.find((section) => !sectionHasContent(body, section));
if (missingSection) {
checks.push(fail(`codex.template.${id}.exists`, `${id} template missing non-empty ${missingSection}`, file));
continue;
}
if (id === "project_config") {
try {
JSON.parse(body);
checks.push(pass(`codex.template.${id}.exists`, `${id} template exists`, file));
} catch (error) {
checks.push(fail(`codex.template.${id}.exists`, `${id} template JSON invalid: ${(error as Error).message}`, file));
}
continue;
}
checks.push(pass(`codex.template.${id}.exists`, `${id} template exists`, file));
}
if (templateFailures.length === 0 && Object.keys(templateRegistry).length === 7) checks.push(pass("templates", "all templates exist"));
const cliHelp = existsSync(path.join(root, "src", "cli.ts")) ? execFileSync(path.join(root, "node_modules", ".bin", "tsx"), [path.join(root, "src", "cli.ts"), "--help"], { cwd: root, encoding: "utf8" }) : "";
checks.push(/\bnext\b/.test(cliHelp) ? fail("codex.surface.future.next", "future next command exposed") : pass("codex.surface.future.next", "future next command is not exposed"));
checks.push(/\btelemetry\b/.test(cliHelp) ? fail("codex.surface.future.telemetry", "future telemetry command exposed") : pass("codex.surface.future.telemetry", "future telemetry command is not exposed"));
checks.push(/\bparallel\b/.test(cliHelp) ? fail("codex.surface.future.parallel", "future parallel command exposed") : pass("codex.surface.future.parallel", "future parallel command is not exposed"));
checks.push(/ownership/i.test(cliHelp) ? fail("codex.surface.future.ownership", "future ownership enforcement surface exposed") : pass("codex.surface.future.ownership", "future ownership enforcement surface is not exposed"));
checks.push(existsSync(path.join(root, "dist", "cli.js")) ? pass("build.output", "dist/cli.js exists") : fail("build.output", "dist/cli.js missing; run npm run build", path.join(root, "dist", "cli.js")));
if (existsSync(path.join(root, "dist", "cli.js"))) {
try {
const packRaw = execFileSync("npm", ["pack", "--json"], { cwd: root, encoding: "utf8" });
const packRaw = execFileSync("npm", ["pack", "--json"], { cwd: root, encoding: "utf8", shell: false });
const packInfo = JSON.parse(packRaw)[0] as { filename: string; files: { path: string }[] };
const packed = new Set(packInfo.files.map((file) => file.path));
for (const need of ["dist/cli.js", "engine_configs/godot.json", "engine_configs/unity.json", "engine_configs/unreal.json", "templates/gdd_template.md", "agents/base/master_orchestrator.md"]) {
for (const need of ["dist/cli.js", "engine_configs/godot.json", "engine_configs/unity.json", "engine_configs/unreal.json", "templates/gdd_template.md", "templates/market_analysis_template.md", "templates/analytics_setup_template.md", "templates/handoff_template.md"]) {
checks.push(packed.has(need) ? pass(`pack.${need}`, `${need} packed`) : fail(`pack.${need}`, `${need} missing from npm pack`));
}
const temp = mkdtempSync(path.join(tmpdir(), "open-gamestudio-pack-"));
try {
execFileSync("npm", ["install", "--silent", "--prefix", temp, path.join(root, packInfo.filename)], { cwd: root, encoding: "utf8" });
execFileSync("npm", ["exec", "--prefix", temp, "open-gamestudio", "--", "templates", "list"], { cwd: temp, encoding: "utf8" });
execFileSync("npm", ["install", "--silent", "--prefix", temp, path.join(root, packInfo.filename)], { cwd: root, encoding: "utf8", shell: false });
execFileSync("npm", ["exec", "--prefix", temp, "open-gamestudio", "--", "templates", "list"], { cwd: temp, encoding: "utf8", shell: false });
checks.push(pass("pack.install_smoke", "installed package bin loads templates from temp cwd"));
} finally {
rmSync(temp, { recursive: true, force: true });
@@ -97,64 +153,60 @@ export async function validateRepo(root = process.cwd()): Promise<ValidationChec
checks.push(fail("pack.install_smoke", `package smoke failed: ${(error as Error).message}`));
}
}
const help = readFileSync(path.join(root, "src", "cli.ts"), "utf8");
checks.push(help.includes('.option("--exec"') ? pass("codex.exec", "direct codex exec option exposed") : fail("codex.exec", "run command must expose --exec for direct Codex integration"));
for (const forbidden of ["next", "telemetry", "parallel orchestration", "ownership enforcement"]) {
checks.push(!help.includes(`command("${forbidden}`) && !help.includes(`option("${forbidden}`) ? pass(`future.absent.${forbidden}`, `${forbidden} not exposed`) : fail(`future.absent.${forbidden}`, `${forbidden} must not be exposed`));
}
return checks;
}
export function validateProject(projectRoot: string): ValidationCheck[] {
const checks: ValidationCheck[] = [];
const configPath = path.join(projectRoot, "project-config.json");
let config;
const studioPath = path.join(projectRoot, ".codex", "studio.json");
let studio: ReturnType<typeof readStudioProject>;
try {
config = readProjectConfig(configPath);
checks.push(pass("project.config", "config schema-valid", configPath));
studio = readStudioProject(projectRoot);
checks.push(pass("codex.project.studio", "studio.json schema-readable", studioPath));
} catch (error) {
return [fail("project.config", `invalid project config: ${(error as Error).message}`, configPath)];
}
const expectedAgents = activeAgentsForMode(config.project.mode);
checks.push(JSON.stringify(expectedAgents) === JSON.stringify(config.team.active_agents) ? pass("project.active_agents", "active agents match mode") : fail("project.active_agents", "active agents do not match mode", configPath));
const engines = loadEngineConfigs(packageAssetPath("engine_configs"));
const root = sourceRoot(projectRoot, config.project.slug);
checks.push(existsSync(root) ? pass("project.source_root", "engine source root exists", root) : fail("project.source_root", "engine source root missing", root));
const engineFile = expectedEngineProjectFile(projectRoot, config);
checks.push(existsSync(engineFile) ? pass("project.engine_file", "engine project file exists", engineFile) : fail("project.engine_file", "engine project file missing", engineFile));
if (config.project.engine === "unity") {
const settings = path.join(root, "ProjectSettings", "ProjectSettings.asset");
checks.push(existsSync(settings) ? pass("project.engine_settings", "Unity ProjectSettings marker exists", settings) : fail("project.engine_settings", "Unity ProjectSettings marker missing", settings));
}
for (const agent of config.team.active_agents) {
const file = path.join(projectRoot, ".gamestudio", "agents", `${agent}.md`);
if (!existsSync(file)) {
checks.push(fail(`project.agent.${agent}`, `${agent} prompt missing`, file));
continue;
}
const body = readFileSync(file, "utf8");
const engine = engines[config.project.engine];
const hasProjectContext =
sectionHasContent(body, "# Project Context") &&
body.includes(`- Name: ${config.project.name}`) &&
body.includes(`- Engine: ${engine.display_name} ${config.project.engine_version}`) &&
sectionHasContent(body, "# Engine Overlay");
const missingSections = requiredAgentSections.filter((section) => !sectionHasContent(body, section));
checks.push(
hasProjectContext && missingSections.length === 0
? pass(`project.agent.${agent}`, `${agent} materialized`, file)
: fail(`project.agent.${agent}`, `${agent} prompt missing project context or non-empty sections: ${missingSections.join(", ") || "project context"}`, file)
);
return [fail("codex.project.studio", `invalid studio state: ${(error as Error).message}`, studioPath)];
}
checks.push(JSON.stringify(studio.roles) === JSON.stringify(studioRoleIds) ? pass("codex.project.roles", "full role roster recorded", studioPath) : fail("codex.project.roles", "studio roles must equal canonical studioRoleIds", studioPath));
checks.push(JSON.stringify(studio.activeRoles) === JSON.stringify(activeAgentsForMode(studio.mode)) ? pass("codex.project.activeRoles", "mode-active roles recorded", studioPath) : fail("codex.project.activeRoles", "activeRoles must match project mode", studioPath));
checks.push(JSON.stringify(studio.workflows) === JSON.stringify(workflowIds()) ? pass("codex.project.workflows", "canonical workflows recorded", studioPath) : fail("codex.project.workflows", "workflows must match registry keys", studioPath));
const agentsMd = path.join(projectRoot, "AGENTS.md");
if (existsSync(agentsMd)) {
const body = readFileSync(agentsMd, "utf8");
const hash = guidanceConfigHash(config);
checks.push(body.includes("generated-by: open-gamestudio src/agents.ts") ? pass("project.agents_md.provenance", "AGENTS.md provenance ok", agentsMd) : fail("project.agents_md.provenance", "AGENTS.md provenance missing", agentsMd));
checks.push(body.includes(`source-config-sha256: ${hash}`) ? pass("project.agents_md.hash", "AGENTS.md hash current", agentsMd) : fail("project.agents_md.hash", "AGENTS.md stale; regenerate project agents", agentsMd));
for (const section of projectAgentsMdRequiredSections) {
checks.push(sectionHasContent(body, section) ? pass(`codex.project.AGENTS.md.${section}`, `${section} exists`, agentsMd) : fail(`codex.project.AGENTS.md.${section}`, `${section} missing content`, agentsMd));
}
} else {
checks.push(fail("project.agents_md", "project AGENTS.md missing", agentsMd));
checks.push(fail("project.agents_md", "AGENTS.md missing", agentsMd));
}
for (const role of studioRoleIds) checks.push(...stablePromptSectionChecks(projectRoot, role, studio.name));
for (const workflow of workflowIds()) {
const file = path.join(projectRoot, workflowRegistry[workflow].file);
if (!existsSync(file)) {
checks.push(fail(`codex.workflow.${workflow}.file.exists`, `${workflow} workflow missing`, file));
continue;
}
const body = readFileSync(file, "utf8");
checks.push(pass(`codex.workflow.${workflow}.file.exists`, `${workflow} workflow exists`, file));
const hasSections = ["## Purpose", "## Inputs", "## Role", "## Outputs", "## Validation"].every((section) => sectionHasContent(body, section));
checks.push(hasSections ? pass(`codex.workflow.${workflow}.sections`, `${workflow} workflow sections exist`, file) : fail(`codex.workflow.${workflow}.sections`, `${workflow} workflow sections missing content`, file));
checks.push(renderWorkflowPrompt(projectRoot, workflow).includes(workflowRegistry[workflow].objective) ? pass(`codex.workflow.${workflow}.render`, `${workflow} workflow renders`) : fail(`codex.workflow.${workflow}.render`, `${workflow} workflow did not render`));
checks.push(pass(`codex.workflow.${workflow}.registry`, `${workflow} registry entry exists`));
checks.push(pass(`codex.workflow.${workflow}`, `${workflow} workflow exists`, file));
}
const root = sourceRoot(projectRoot, studio.slug);
checks.push(existsSync(root) ? pass("project.source_root", "engine source root exists", root) : fail("project.source_root", "engine source root missing", root));
const engineFile = studio.engine === "godot" ? path.join(root, "project.godot") : studio.engine === "unity" ? path.join(root, "Packages", "manifest.json") : path.join(root, unrealProjectFileName(studio.name));
checks.push(existsSync(engineFile) ? pass("project.engine_file", "engine project file exists", engineFile) : fail("project.engine_file", "engine project file missing", engineFile));
if (studio.engine === "unity") {
const settings = path.join(root, "ProjectSettings", "ProjectSettings.asset");
checks.push(existsSync(settings) ? pass("project.engine_settings", "Unity ProjectSettings marker exists", settings) : fail("project.engine_settings", "Unity ProjectSettings marker missing", settings));
}
for (const file of ["resources/market-research/market-overview.md", "documentation/design/gdd.md", "documentation/production/timeline.md"]) {
checks.push(existsSync(path.join(projectRoot, file)) ? pass(`project.artifact.${file}`, `${file} exists`) : fail(`project.artifact.${file}`, `${file} missing`, path.join(projectRoot, file)));
}
@@ -165,11 +217,17 @@ export function validateProject(projectRoot: string): ValidationCheck[] {
checks.push(sectionHasContent(body, section) ? pass(`project.timeline.${section}`, `${section} exists`, timeline) : fail(`project.timeline.${section}`, `${section} missing non-empty content`, timeline));
}
}
const before = JSON.stringify(readFileSync(configPath, "utf8"));
for (const forbidden of ["project_orchestrator.md", "CODEX.md", path.join(".gamestudio", "runs")]) {
const file = path.join(projectRoot, forbidden);
checks.push(existsSync(file) ? fail(`project.forbidden.${forbidden}`, `${forbidden} must not exist`, file) : pass(`project.forbidden.${forbidden}`, `${forbidden} absent`));
}
const before = readFileSync(studioPath, "utf8");
statusProject(projectRoot, path.dirname(projectRoot));
resumeProject(projectRoot, path.dirname(projectRoot));
const after = JSON.stringify(readFileSync(configPath, "utf8"));
checks.push(before === after ? pass("project.read_only", "status/resume are read-only") : fail("project.read_only", "status/resume mutated project", configPath));
const after = readFileSync(studioPath, "utf8");
checks.push(before === after ? pass("project.read_only", "status/resume are read-only") : fail("project.read_only", "status/resume mutated project", studioPath));
return checks;
}
+69
View File
@@ -0,0 +1,69 @@
import { spawn } from "node:child_process";
export type VerificationCommand = {
command: string;
args: string[];
};
export type VerificationResult = {
command: string;
args: string[];
cwd: string;
exitCode: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
timedOut: boolean;
};
export type RunVerificationOptions = {
cwd: string;
timeoutMs?: number;
maxOutputBytes?: number;
};
function appendBounded(current: string, chunk: string, max: number): string {
const next = current + chunk;
if (Buffer.byteLength(next, "utf8") <= max) return next;
return next.slice(Math.max(0, next.length - max));
}
export async function runVerificationCommand(command: VerificationCommand, options: RunVerificationOptions): Promise<VerificationResult> {
if (!command.command.trim()) throw new Error("verification command is required");
const timeoutMs = options.timeoutMs ?? 30_000;
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("verification timeout must be finite and positive");
const maxOutputBytes = options.maxOutputBytes ?? 64_000;
if (!Number.isFinite(maxOutputBytes) || maxOutputBytes <= 0) throw new Error("verification output bound must be finite and positive");
return await new Promise((resolve) => {
const child = spawn(command.command, command.args, {
cwd: options.cwd,
shell: false,
stdio: ["ignore", "pipe", "pipe"]
});
let stdout = "";
let stderr = "";
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
}, timeoutMs);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout = appendBounded(stdout, chunk, maxOutputBytes);
});
child.stderr.on("data", (chunk: string) => {
stderr = appendBounded(stderr, chunk, maxOutputBytes);
});
child.on("error", (error) => {
clearTimeout(timer);
resolve({ command: command.command, args: command.args, cwd: options.cwd, exitCode: null, signal: null, stdout, stderr: appendBounded(stderr, error.message, maxOutputBytes), timedOut });
});
child.on("close", (exitCode, signal) => {
clearTimeout(timer);
resolve({ command: command.command, args: command.args, cwd: options.cwd, exitCode, signal, stdout, stderr, timedOut });
});
});
}
+195
View File
@@ -0,0 +1,195 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { createCodexStudioSession, type CodexStudioPhase } from "./codex-session.js";
import { renderCodexPrompt } from "./codex-prompts.js";
import type { EngineId } from "./engines.js";
import type { StudioRoleId } from "./roles.js";
import { readTemplate, templateRegistry, type TemplateId } from "./templates.js";
export type WorkflowId =
| "vertical-slice"
| "bugfix"
| "playtest"
| "market-analysis"
| "analytics-setup"
| "design-spec"
| "game-feel-tuning"
| "art-direction"
| "ui-ux-review"
| "production-milestone"
| "handoff"
| "review"
| "ship-check";
export type WorkflowDefinition = {
id: WorkflowId;
role: StudioRoleId;
phase: Extract<CodexStudioPhase, "plan" | "implement" | "review" | "ship">;
objective: string;
file: `.codex/workflows/${string}.md`;
contextFiles: string[];
templateIds?: TemplateId[];
cliAlias?: string;
};
export const workflowRegistry: Record<WorkflowId, WorkflowDefinition> = {
"vertical-slice": {
id: "vertical-slice",
role: "producer",
phase: "plan",
objective: "Create a bounded vertical-slice plan with tasks, risks, and verification gates.",
file: ".codex/workflows/vertical-slice.md",
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/workflows/vertical-slice.md", "documentation/design/gdd.md"]
},
bugfix: {
id: "bugfix",
role: "gameplay-programmer",
phase: "implement",
objective: "Reproduce, fix, verify, and document a defect with bounded scope.",
file: ".codex/workflows/bugfix.md",
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/workflows/bugfix.md"]
},
playtest: {
id: "playtest",
role: "qa-playtester",
phase: "review",
objective: "Inspect the current build, report reproducible playtest issues, and separate blockers from warnings.",
file: ".codex/workflows/playtest.md",
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/workflows/playtest.md"]
},
"market-analysis": {
id: "market-analysis",
role: "market-analyst",
phase: "plan",
objective: "Analyze audience, competitors, positioning, pricing, and market risks for the current project.",
file: ".codex/workflows/market-analysis.md",
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/workflows/market-analysis.md", "resources/market-research/market-overview.md"],
templateIds: ["market_analysis"],
cliAlias: "market"
},
"analytics-setup": {
id: "analytics-setup",
role: "data-scientist",
phase: "plan",
objective: "Define analytics events, success metrics, experiment plans, and evidence loops for the current project.",
file: ".codex/workflows/analytics-setup.md",
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/workflows/analytics-setup.md"],
templateIds: ["analytics_setup"],
cliAlias: "analytics"
},
"design-spec": {
id: "design-spec",
role: "senior-game-designer",
phase: "plan",
objective: "Create or review a feature/design spec with rules, edge cases, implementation slices, and acceptance criteria.",
file: ".codex/workflows/design-spec.md",
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/workflows/design-spec.md", "documentation/design/gdd.md"],
templateIds: ["feature_spec"],
cliAlias: "design-spec"
},
"game-feel-tuning": {
id: "game-feel-tuning",
role: "game-feel-designer",
phase: "review",
objective: "Review moment-to-moment feel, controls, feedback, pacing, and tuning risks with actionable changes.",
file: ".codex/workflows/game-feel-tuning.md",
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/workflows/game-feel-tuning.md"],
cliAlias: "feel-review"
},
"art-direction": {
id: "art-direction",
role: "senior-game-artist",
phase: "plan",
objective: "Define art direction, visual constraints, asset list, production risks, and review criteria.",
file: ".codex/workflows/art-direction.md",
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/workflows/art-direction.md"],
cliAlias: "art-direction"
},
"ui-ux-review": {
id: "ui-ux-review",
role: "ui-ux-designer",
phase: "review",
objective: "Review UI flows, HUD/menu clarity, usability, onboarding, accessibility, and interaction risks.",
file: ".codex/workflows/ui-ux-review.md",
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/workflows/ui-ux-review.md"],
cliAlias: "ui-review"
},
"production-milestone": {
id: "production-milestone",
role: "producer",
phase: "plan",
objective: "Convert current project state into milestone goals, task slices, risks, owners, and verification gates.",
file: ".codex/workflows/production-milestone.md",
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/workflows/production-milestone.md", "documentation/production/timeline.md"],
cliAlias: "milestone"
},
handoff: {
id: "handoff",
role: "studio-orchestrator",
phase: "plan",
objective: "Summarize current state, route next work to the right role, identify blockers, and produce a concise handoff.",
file: ".codex/workflows/handoff.md",
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/workflows/handoff.md"],
templateIds: ["handoff"],
cliAlias: "handoff"
},
review: {
id: "review",
role: "qa-playtester",
phase: "review",
objective: "Review the current project state and report blockers, warnings, and verification gaps as JSON.",
file: ".codex/workflows/review.md",
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/workflows/review.md"]
},
"ship-check": {
id: "ship-check",
role: "release-manager",
phase: "ship",
objective: "Assess milestone readiness, package risk, validation status, and release blockers.",
file: ".codex/workflows/ship-check.md",
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/workflows/ship-check.md", "documentation/production/timeline.md"]
}
};
function renderWorkflowTemplates(workflow: WorkflowId): string {
const templateIds = workflowRegistry[workflow].templateIds ?? [];
if (templateIds.length === 0) return "";
return [
"",
"## Workflow Templates",
"",
...templateIds.flatMap((id) => {
const info = templateRegistry[id];
return [`### Template: ${id}`, `Source: package:${info.path}`, "", readTemplate(id).trim(), ""];
})
].join("\n");
}
function readStudioEngine(projectRoot: string): EngineId {
const studio = JSON.parse(readFileSync(path.join(projectRoot, ".codex", "studio.json"), "utf8")) as { engine: EngineId };
return studio.engine;
}
export function workflowIds(): WorkflowId[] {
return Object.keys(workflowRegistry) as WorkflowId[];
}
export function workflowForAlias(alias: string): WorkflowDefinition | undefined {
return Object.values(workflowRegistry).find((workflow) => workflow.cliAlias === alias);
}
export function renderWorkflowPrompt(projectRoot: string, workflow: WorkflowId): string {
const config = workflowRegistry[workflow];
return (
renderCodexPrompt(
createCodexStudioSession({
projectRoot,
role: config.role,
phase: config.phase,
objective: config.objective,
engine: readStudioEngine(projectRoot),
contextFiles: config.contextFiles
})
) + renderWorkflowTemplates(workflow)
);
}
+5 -7
View File
@@ -18,13 +18,11 @@
},
"team": {
"active_agents": [
"master_orchestrator",
"producer_agent",
"market_analyst",
"data_scientist",
"sr_game_designer",
"mechanics_developer",
"qa_agent"
"creative-director",
"producer",
"game-designer",
"gameplay-programmer",
"qa-playtester"
]
},
"production": {
+6 -6
View File
@@ -6,7 +6,7 @@ import { formatTemplateShow, listTemplates, readTemplate, selectTemplates, valid
describe("config, agents, and templates", () => {
test("slug, active agents, and canonical hash are deterministic", () => {
expect(slugify("My Game")).toBe("my-game");
expect(activeAgentsForMode("prototype")).toContain("qa_agent");
expect(activeAgentsForMode("prototype")).toContain("qa-playtester");
const config = projectConfigSchema.parse(JSON.parse(readTemplate("project_config")));
const hash = guidanceConfigHash(config);
config.project.status = "frozen";
@@ -31,10 +31,10 @@ describe("config, agents, and templates", () => {
});
test("template selection is bounded", () => {
expect(selectTemplates("market_analyst", "Create market overview")).toEqual(["market_analysis"]);
expect(selectTemplates("data_scientist", "Create analytics plan")).toEqual(["analytics_setup"]);
expect(selectTemplates("qa_agent", "Review validation readiness")).toEqual([]);
expect(selectTemplates("producer_agent", "handoff coordination")).toEqual(["handoff"]);
expect(selectTemplates("producer", "Create market overview")).toEqual(["market_analysis"]);
expect(selectTemplates("producer", "Create analytics plan")).toEqual(["analytics_setup"]);
expect(selectTemplates("qa-playtester", "Review validation readiness")).toEqual([]);
expect(selectTemplates("producer", "handoff coordination")).toEqual(["handoff"]);
});
test("template show includes discoverability metadata before body", () => {
@@ -42,7 +42,7 @@ describe("config, agents, and templates", () => {
expect(output).toContain("ID: gdd");
expect(output).toContain("Category: design");
expect(output).toContain("Path: templates/gdd_template.md");
expect(output).toContain("Roles: sr_game_designer, mid_game_designer");
expect(output).toContain("Roles: game-designer, creative-director");
expect(output).toContain("# Purpose");
});
});
+30
View File
@@ -0,0 +1,30 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { initProject } from "../src/projects.js";
import { prepareRun } from "../src/runner.js";
import { createTask, renderTaskRun } from "../src/tasks.js";
import { renderWorkflowPrompt } from "../src/workflows.js";
describe("Codex context files", () => {
test("runner, workflows, and task prompts use AGENTS.md", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-context-"));
const { projectRoot } = initProject({ name: "Context Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const prepared = prepareRun("gameplay-programmer", { project: projectRoot, task: "Implement movement", dryRun: true }, cwd);
expect(prepared.contextFiles[0]).toBe("AGENTS.md");
expect(prepared.prompt).toContain("AGENTS.md");
expect(prepared.output).toContain("- AGENTS.md");
expect(prepared.prompt).not.toContain("CODEX.md");
const workflow = renderWorkflowPrompt(projectRoot, "vertical-slice");
expect(workflow).toContain("AGENTS.md");
expect(workflow).not.toContain("CODEX.md");
const task = createTask(projectRoot, { title: "Wire jump controls", role: "gameplay-programmer" });
const taskRun = renderTaskRun(projectRoot, task.id);
expect(taskRun.prompt).toContain("AGENTS.md");
expect(taskRun.prompt).not.toContain("CODEX.md");
});
});
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, test } from "vitest";
import { createCodexStudioSession } from "../src/codex-session.js";
import { renderCodexPrompt } from "../src/codex-prompts.js";
describe("Codex prompt renderer", () => {
test("renders role, objective, context, outputs, and verification contract", () => {
const session = createCodexStudioSession({
projectRoot: "/repo/projects/rogue-core",
role: "gameplay-programmer",
objective: "Implement movement",
phase: "implement",
engine: "godot",
contextFiles: ["AGENTS.md", ".codex/studio.json"],
verification: { command: "npm", args: ["test"] }
});
const prompt = renderCodexPrompt(session);
expect(prompt).toContain("Role: Gameplay Programmer");
expect(prompt).toContain("Objective: Implement movement");
expect(prompt).toContain("Phase: implement");
expect(prompt).toContain("AGENTS.md");
expect(prompt).not.toContain("CODEX.md");
expect(prompt).toContain("npm test");
expect(prompt).toContain("project.godot");
expect(prompt).toContain("godot --headless");
expect(prompt).toContain("Prefer Godot");
expect(prompt).toContain("changed files");
expect(prompt).toContain("verification results");
});
});
+41
View File
@@ -0,0 +1,41 @@
import { chmodSync, writeFileSync } from "node:fs";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { buildCodexExecArgs, checkCodexAvailability, resolveCodexCommand } from "../src/codex-runtime.js";
describe("codex runtime", () => {
test("resolves Codex command from CODEX_BIN only", () => {
expect(resolveCodexCommand({ CODEX_BIN: "codex-custom" })).toBe("codex-custom");
expect(resolveCodexCommand({ OPEN_GAMESTUDIO_CODEX_BIN: "legacy-codex" })).toBe("codex");
expect(resolveCodexCommand({})).toBe("codex");
});
test("builds structured codex exec args with cwd, sandbox, and stdin prompt", () => {
const args = buildCodexExecArgs({ projectRoot: "/repo", sandbox: "read-only" });
expect(args).toContain("exec");
expect(args).toContain("--cd");
expect(args).toContain("/repo");
expect(args).toContain("--sandbox");
expect(args).toContain("read-only");
expect(args).toContain("-");
});
test("reports availability failures without throwing", async () => {
const result = await checkCodexAvailability({ codexBin: "/missing/codex" });
expect(result.ok).toBe(false);
expect(result.command).toBe("/missing/codex");
expect(result.reason).toMatch(/not found|unavailable/i);
});
test("reports authenticated stub as available", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-codex-"));
const stub = path.join(cwd, "codex-stub.mjs");
writeFileSync(stub, "#!/usr/bin/env node\nconsole.log('codex 1.0.0');\n");
chmodSync(stub, 0o755);
const result = await checkCodexAvailability({ codexBin: stub });
expect(result.ok).toBe(true);
expect(result.command).toBe(stub);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, test } from "vitest";
import { createCodexStudioSession, validateCodexStudioSession } from "../src/codex-session.js";
describe("Codex studio sessions", () => {
test("constructs default sandbox settings by phase", () => {
const plan = createCodexStudioSession({
projectRoot: "/repo/projects/game",
role: "producer",
objective: "Plan the milestone",
phase: "plan"
});
expect(plan.allowFileEdits).toBe(false);
expect(plan.sandbox).toBe("read-only");
expect(plan.contextFiles).toEqual(["AGENTS.md", ".codex/studio.json"]);
const impl = createCodexStudioSession({
projectRoot: "/repo/projects/game",
role: "gameplay-programmer",
objective: "Implement movement",
phase: "implement"
});
expect(impl.allowFileEdits).toBe(true);
expect(impl.sandbox).toBe("workspace-write");
});
test("rejects invalid sessions and writable sandbox without file edits", () => {
expect(() =>
validateCodexStudioSession({
projectRoot: "",
role: "producer",
objective: "x",
phase: "plan",
contextFiles: [],
expectedOutputs: [],
allowFileEdits: false,
sandbox: "read-only"
})
).toThrow(/project root/i);
expect(() =>
createCodexStudioSession({
projectRoot: "/repo",
role: "producer",
objective: "x",
phase: "review",
sandbox: "workspace-write",
allowFileEdits: false
})
).toThrow(/writable sandbox/i);
});
});
+4
View File
@@ -14,6 +14,10 @@ describe("engine registry", () => {
expect(normalizeEngine("Unreal Engine", registry)).toBe("unreal");
expect(normalizeEngine("ue5", registry)).toBe("unreal");
expect(() => normalizeEngine("scratch", registry)).toThrow(/Unknown engine/);
expect(registry.godot.project_files).toContain("project.godot");
expect(registry.unity.run_command).toContain("Unity");
expect(registry.unreal.test_command).toContain("Automation");
expect(registry.godot.codex_hints.length).toBeGreaterThan(0);
});
test("creates engine roots under source/project-slug", () => {
+170
View File
@@ -0,0 +1,170 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { activeAgentsForMode } from "../src/config.js";
import { initProject, statusProject } from "../src/projects.js";
import { rolePackages, studioRoleIds } from "../src/roles.js";
import { readTemplate, templateRegistry } from "../src/templates.js";
import { validateProject } from "../src/validation.js";
import { renderWorkflowPrompt, workflowRegistry } from "../src/workflows.js";
const requiredRoles = [
"studio-orchestrator",
"producer",
"market-analyst",
"data-scientist",
"creative-director",
"senior-game-designer",
"game-designer",
"narrative-designer",
"game-feel-designer",
"gameplay-programmer",
"engine-programmer",
"tools-programmer",
"senior-game-artist",
"technical-artist",
"ui-ux-designer",
"qa-playtester",
"release-manager"
] as const;
describe("functionality gap pass", () => {
it("exposes Codex-native roles for the full studio function set", () => {
expect(studioRoleIds).toEqual(requiredRoles);
for (const role of requiredRoles) {
expect(rolePackages[role].systemPrompt.length).toBeGreaterThan(80);
expect(rolePackages[role].expectedOutputs.length).toBeGreaterThanOrEqual(2);
expect(rolePackages[role].reviewChecklist.length).toBeGreaterThanOrEqual(2);
}
expect(studioRoleIds).not.toContain("producer_agent");
expect(studioRoleIds).not.toContain("qa_agent");
expect(studioRoleIds).not.toContain("master_orchestrator");
});
it("selects market, analytics, orchestration, and mode-specific roles", () => {
for (const mode of ["design", "prototype", "development"] as const) {
expect(activeAgentsForMode(mode)).toEqual(
expect.arrayContaining(["studio-orchestrator", "producer", "market-analyst", "data-scientist"])
);
}
expect(activeAgentsForMode("design")).toEqual(
expect.arrayContaining(["creative-director", "senior-game-designer", "game-designer", "narrative-designer", "senior-game-artist", "ui-ux-designer"])
);
expect(activeAgentsForMode("prototype")).toEqual(
expect.arrayContaining(["senior-game-designer", "game-feel-designer", "gameplay-programmer", "qa-playtester"])
);
expect(activeAgentsForMode("development")).toEqual(
expect.arrayContaining(["game-feel-designer", "engine-programmer", "tools-programmer", "technical-artist", "ui-ux-designer", "release-manager"])
);
});
it("materializes project-specific prompts, activeRoles, and registry workflows", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-gap-"));
const { projectRoot } = initProject(
{ name: "Test Studio Game", engine: "godot", mode: "prototype", competitors: ["Mini Metro"], nonInteractive: true },
cwd
);
const studio = JSON.parse(readFileSync(path.join(projectRoot, ".codex", "studio.json"), "utf8"));
expect(studio.roles).toEqual(requiredRoles);
expect(studio.activeRoles).toEqual(activeAgentsForMode("prototype"));
expect(studio.workflows).toEqual(Object.keys(workflowRegistry));
expect(statusProject(projectRoot, cwd)).toContain(`active roles: ${activeAgentsForMode("prototype").join(", ")}`);
const promptBody = readFileSync(path.join(projectRoot, ".codex", "prompts", "market-analyst.md"), "utf8");
expect(promptBody).toContain("Project: Test Studio Game");
expect(promptBody).toContain("Role: Market Analyst");
expect(promptBody).toContain("Engine:");
expect(promptBody).toContain("Current Milestone:");
expect(promptBody).toContain("Expected Outputs");
expect(promptBody).toContain("Review Checklist");
expect(promptBody).toContain("Handoff");
expect(promptBody).toContain("Competitors: Mini Metro");
for (const workflow of Object.keys(workflowRegistry)) {
expect(existsSync(path.join(projectRoot, workflowRegistry[workflow as keyof typeof workflowRegistry].file))).toBe(true);
expect(renderWorkflowPrompt(projectRoot, workflow as keyof typeof workflowRegistry)).toContain(workflow);
}
expect(existsSync(path.join(projectRoot, "project_orchestrator.md"))).toBe(false);
expect(existsSync(path.join(projectRoot, "CODEX.md"))).toBe(false);
expect(existsSync(path.join(projectRoot, ".gamestudio", "runs"))).toBe(false);
});
it("inlines only selected package templates into workflow prompts", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-gap-"));
const { projectRoot } = initProject({ name: "Template Game", engine: "godot", mode: "design", nonInteractive: true }, cwd);
for (const workflow of Object.values(workflowRegistry)) {
for (const templateId of workflow.templateIds ?? []) {
expect(templateRegistry[templateId].roles).toContain(workflow.role);
}
}
const market = renderWorkflowPrompt(projectRoot, "market-analysis");
expect(market).toContain("Template: market_analysis");
expect(market).toContain("Source: package:templates/market_analysis_template.md");
expect(market).toContain(readTemplate("market_analysis").trim());
expect(market).not.toContain("Template: analytics_setup");
const analytics = renderWorkflowPrompt(projectRoot, "analytics-setup");
expect(analytics).toContain("Template: analytics_setup");
expect(analytics).toContain("Source: package:templates/analytics_setup_template.md");
expect(analytics).toContain(readTemplate("analytics_setup").trim());
expect(analytics).not.toContain("Template: market_analysis");
expect(renderWorkflowPrompt(projectRoot, "design-spec")).toContain("Template: feature_spec");
expect(renderWorkflowPrompt(projectRoot, "handoff")).toContain("Template: handoff");
expect(renderWorkflowPrompt(projectRoot, "ui-ux-review")).not.toContain("## Workflow Templates");
expect(renderWorkflowPrompt(projectRoot, "review")).not.toContain("## Workflow Templates");
expect(market).not.toMatch(/- templates\/.*\.md/);
});
it("workflow shortcut CLI aliases render prompts only", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-gap-cli-"));
const { projectRoot } = initProject({ name: "Shortcut Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const cli = path.join(process.cwd(), "src", "cli.ts");
const tsx = path.join(process.cwd(), "node_modules", ".bin", "tsx");
const help = execFileSync(tsx, [cli, "--help"], { encoding: "utf8" });
for (const alias of ["market", "analytics", "design-spec", "feel-review", "art-direction", "ui-review", "milestone", "handoff"]) {
expect(help).toContain(alias);
}
expect(help).not.toMatch(/\bplan\b/);
expect(help).not.toMatch(/\bnext\b/);
expect(help).not.toMatch(/\btelemetry\b/);
expect(help).not.toMatch(/\bparallel\b/);
process.env.CODEX_BIN = "/missing/codex";
try {
for (const workflow of Object.values(workflowRegistry).filter((entry) => entry.cliAlias)) {
for (const args of [[workflow.cliAlias!], [workflow.cliAlias!, "--dry-run"]]) {
const before = readdirSync(path.join(projectRoot, ".codex", "runs"));
const output = execFileSync(tsx, [cli, ...args, "--project", projectRoot], { cwd, encoding: "utf8" });
expect(output).toContain(`Role: ${rolePackages[workflow.role].displayName}`);
expect(output).toContain(workflow.objective);
expect(readdirSync(path.join(projectRoot, ".codex", "runs"))).toEqual(before);
}
}
} finally {
delete process.env.CODEX_BIN;
}
});
it("validation reports stable IDs for missing expanded prompts and workflows", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-gap-val-"));
const { projectRoot } = initProject({ name: "Broken Gap Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
rmSync(path.join(projectRoot, ".codex", "prompts", "studio-orchestrator.md"));
rmSync(path.join(projectRoot, ".codex", "workflows", "market-analysis.md"));
writeFileSync(path.join(projectRoot, ".codex", "workflows", "ui-ux-review.md"), "# UI UX Review\n");
const marketPrompt = path.join(projectRoot, ".codex", "prompts", "market-analyst.md");
writeFileSync(marketPrompt, readFileSync(marketPrompt, "utf8").replace("Project: Broken Gap Game", "Project: Wrong Game").replace("Role: Market Analyst", "Role: Wrong Role"));
const failures = validateProject(projectRoot).filter((check) => check.status === "fail").map((check) => check.id);
expect(failures).toContain("codex.role.studio-orchestrator.prompt.exists");
expect(failures).toContain("codex.role.market-analyst.prompt.project");
expect(failures).toContain("codex.role.market-analyst.prompt.role");
expect(failures).toContain("codex.workflow.market-analysis.file.exists");
expect(failures).toContain("codex.workflow.ui-ux-review.sections");
});
});
+33 -11
View File
@@ -4,18 +4,40 @@ import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { guidanceConfigHash, readProjectConfig } from "../src/config.js";
import { guidanceConfigHash } from "../src/config.js";
import { freezeProject, initProject, resumeProject, statusProject } from "../src/projects.js";
describe("project workflow", () => {
test("init creates project docs, config, agents, and engine files", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-project-"));
const { projectRoot, config } = initProject({ name: "Test Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
expect(existsSync(path.join(projectRoot, "project-config.json"))).toBe(true);
expect(existsSync(path.join(projectRoot, "project-config.json"))).toBe(false);
expect(existsSync(path.join(projectRoot, "CODEX.md"))).toBe(false);
expect(existsSync(path.join(projectRoot, ".codex", "studio.json"))).toBe(true);
expect(existsSync(path.join(projectRoot, ".codex", "runs"))).toBe(true);
expect(existsSync(path.join(projectRoot, ".codex", "prompts", "producer.md"))).toBe(true);
expect(existsSync(path.join(projectRoot, ".codex", "prompts", "gameplay-programmer.md"))).toBe(true);
expect(existsSync(path.join(projectRoot, ".codex", "prompts", "qa-playtester.md"))).toBe(true);
expect(existsSync(path.join(projectRoot, ".codex", "workflows", "vertical-slice.md"))).toBe(true);
expect(existsSync(path.join(projectRoot, ".codex", "workflows", "bugfix.md"))).toBe(true);
expect(existsSync(path.join(projectRoot, ".codex", "workflows", "playtest.md"))).toBe(true);
expect(existsSync(path.join(projectRoot, ".gamestudio", "runs"))).toBe(false);
expect(JSON.parse(readFileSync(path.join(projectRoot, ".codex", "studio.json"), "utf8"))).toMatchObject({
schemaVersion: 1,
product: "codex-game-studio",
engine: "godot",
currentMilestone: "prototype"
});
const agents = readFileSync(path.join(projectRoot, "AGENTS.md"), "utf8");
expect(agents).toContain("# Test Game Agents");
for (const section of ["## Project Goal", "## Engine", "## Commands", "## Coding Conventions", "## Asset Conventions", "## Studio Roles", "## Current Milestone", "## Verification", "## Rules"]) {
expect(agents).toContain(section);
}
expect(agents).not.toContain("CODEX.md");
expect(existsSync(path.join(projectRoot, "source", "project-test-game", "project.godot"))).toBe(true);
expect(existsSync(path.join(projectRoot, "resources", "market-research", "market-overview.md"))).toBe(true);
expect(existsSync(path.join(projectRoot, "documentation", "design", "gdd.md"))).toBe(true);
expect(existsSync(path.join(projectRoot, ".gamestudio", "agents", "master_orchestrator.md"))).toBe(true);
expect(existsSync(path.join(projectRoot, ".gamestudio"))).toBe(false);
expect(readFileSync(path.join(projectRoot, "AGENTS.md"), "utf8")).toContain(guidanceConfigHash(config));
expect(config.project.concept).toBe("Test Game concept");
expect(config.project.genre).toBe("Unspecified");
@@ -65,9 +87,8 @@ describe("project workflow", () => {
"--engine-version",
"4.5.custom"
], { cwd, encoding: "utf8" });
const config = readProjectConfig(path.join(cwd, "projects", "cli-game", "project-config.json"));
expect(config.project.competitors).toEqual(["terra nil", "mini metro"]);
expect(config.project.engine_version).toBe("4.5.custom");
const studio = JSON.parse(readFileSync(path.join(cwd, "projects", "cli-game", ".codex", "studio.json"), "utf8"));
expect(studio.engineVersion).toBe("4.5.custom");
});
test("CLI init does not expose arbitrary project root override", () => {
@@ -101,13 +122,14 @@ describe("project workflow", () => {
test("status resume are read-only and freeze only changes operational status", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-status-"));
const { projectRoot } = initProject({ name: "Freeze Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const before = readFileSync(path.join(projectRoot, "project-config.json"), "utf8");
const studioPath = path.join(projectRoot, ".codex", "studio.json");
const before = readFileSync(studioPath, "utf8");
expect(statusProject(projectRoot, cwd)).toContain("status: active");
expect(resumeProject(projectRoot, cwd)).toContain("Suggested next command");
expect(readFileSync(path.join(projectRoot, "project-config.json"), "utf8")).toBe(before);
const hash = guidanceConfigHash(readProjectConfig(path.join(projectRoot, "project-config.json")));
expect(readFileSync(studioPath, "utf8")).toBe(before);
const agentsBefore = readFileSync(path.join(projectRoot, "AGENTS.md"), "utf8");
freezeProject(projectRoot, cwd);
expect(readProjectConfig(path.join(projectRoot, "project-config.json")).project.status).toBe("frozen");
expect(guidanceConfigHash(readProjectConfig(path.join(projectRoot, "project-config.json")))).toBe(hash);
expect(JSON.parse(readFileSync(studioPath, "utf8")).status).toBe("frozen");
expect(readFileSync(path.join(projectRoot, "AGENTS.md"), "utf8")).toBe(agentsBefore);
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, test } from "vitest";
import { isStudioRoleId, rolePackages, studioRoleIds, unknownStudioRoleMessage } from "../src/roles.js";
describe("Codex role packages", () => {
test("defines all canonical StudioRoleId packages", () => {
expect(studioRoleIds).toEqual([
"studio-orchestrator",
"producer",
"market-analyst",
"data-scientist",
"creative-director",
"senior-game-designer",
"game-designer",
"narrative-designer",
"game-feel-designer",
"gameplay-programmer",
"engine-programmer",
"tools-programmer",
"senior-game-artist",
"technical-artist",
"ui-ux-designer",
"qa-playtester",
"release-manager"
]);
expect(Object.keys(rolePackages).sort()).toEqual([...studioRoleIds].sort());
});
test("role packages have non-empty Codex contracts", () => {
for (const role of Object.values(rolePackages)) {
expect(role.displayName).toMatch(/\S/);
expect(role.systemPrompt).toMatch(/\S/);
expect(role.expectedOutputs.length).toBeGreaterThan(0);
expect(role.handoffTemplate).toMatch(/\S/);
expect(role.reviewChecklist.length).toBeGreaterThan(0);
}
});
test("legacy underscore aliases are rejected with guidance", () => {
for (const alias of ["producer_agent", "qa_agent", "master_orchestrator"]) {
expect(isStudioRoleId(alias)).toBe(false);
expect(unknownStudioRoleMessage(alias)).toContain("Codex-native hyphenated role IDs");
}
});
});
-100
View File
@@ -1,100 +0,0 @@
import { chmodSync, existsSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { initProject } from "../src/projects.js";
import { codexExecInvocation, executeCodexRun, prepareRun } from "../src/runner.js";
describe("bounded runner", () => {
test("requires non-empty task", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-run-"));
const { projectRoot } = initProject({ name: "Run Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
expect(() => prepareRun("qa_agent", { project: projectRoot, task: "" }, cwd)).toThrow(/--task/);
});
test("writes prompt cache and metadata with bounded context", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-run-"));
const { projectRoot } = initProject({ name: "Market Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const result = prepareRun("market_analyst", { project: projectRoot, task: "Create first market overview", printPrompt: true }, cwd);
expect(existsSync(result.promptPath)).toBe(true);
expect(existsSync(result.metadataPath)).toBe(true);
expect(result.prompt).toContain("# Template: market_analysis");
expect(result.prompt).toContain("resources/market-research/market-analysis.md");
expect(result.prompt).not.toContain("# Template: analytics_setup");
expect(result.prompt).not.toContain("Data scientist prompt");
const metadata = JSON.parse(readFileSync(result.metadataPath, "utf8"));
expect(metadata.prompt_chars).toBe(result.prompt.length);
expect(metadata.prompt_cache_path).toContain("prompt.md");
});
test("dry-run lists context and explicit artifacts only", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-run-"));
const { projectRoot } = initProject({ name: "Qa Game", engine: "Unreal Engine", mode: "development", nonInteractive: true }, cwd);
writeFileSync(path.join(projectRoot, "documentation", "design", "note.md"), "# Note\n");
const result = prepareRun(
"qa_agent",
{ project: projectRoot, task: "Review validation readiness", dryRun: true, includeArtifact: ["documentation/design/note.md"] },
cwd
);
expect(result.output).toContain("Context files:");
expect(result.output).toContain("documentation/design/note.md");
expect(result.prompt).toContain("Unreal Engine");
expect(result.prompt).toContain("npm run validate");
expect(() => prepareRun("qa_agent", { project: projectRoot, task: "x", includeArtifact: ["../outside.md"] }, cwd)).toThrow(/escape/);
});
test("included artifacts cannot escape through project-local symlinks", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-run-"));
const { projectRoot } = initProject({ name: "Symlink Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const outside = path.join(cwd, "outside.md");
writeFileSync(outside, "# Secret\n");
symlinkSync(outside, path.join(projectRoot, "documentation", "design", "outside-link.md"));
expect(() =>
prepareRun("qa_agent", { project: projectRoot, task: "x", includeArtifact: ["documentation/design/outside-link.md"] }, cwd)
).toThrow(/escape/);
});
test("prompt cache paths are unique for repeated runs", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-run-"));
const { projectRoot } = initProject({ name: "Unique Run Game", engine: "unity", mode: "prototype", nonInteractive: true }, cwd);
const a = prepareRun("data_scientist", { project: projectRoot, task: "Create analytics plan" }, cwd);
const b = prepareRun("data_scientist", { project: projectRoot, task: "Create analytics plan" }, cwd);
expect(a.promptPath).not.toBe(b.promptPath);
expect(a.metadataPath).not.toBe(b.metadataPath);
});
test("same inputs produce same deterministic prompt body aside from metadata path", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-run-"));
const { projectRoot } = initProject({ name: "Stable Game", engine: "unity", mode: "prototype", nonInteractive: true }, cwd);
const a = prepareRun("data_scientist", { project: projectRoot, task: "Create analytics plan" }, cwd).prompt;
const b = prepareRun("data_scientist", { project: projectRoot, task: "Create analytics plan" }, cwd).prompt;
expect(a).toBe(b);
expect(a).toContain("# Template: analytics_setup");
expect(a).toContain("documentation/technical/analytics/analytics-plan.md");
});
test("builds a direct codex exec invocation for prepared prompts", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-run-"));
const { projectRoot } = initProject({ name: "Codex Exec Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const result = prepareRun("qa_agent", { project: projectRoot, task: "Review validation readiness", exec: true, codexBin: "codex-test" }, cwd);
expect(result.output).toContain("Executing Codex:");
expect(result.codexCommand.command).toBe("codex-test");
expect(result.codexCommand.args.slice(0, 3)).toEqual(["exec", "--cd", projectRoot]);
expect(result.codexCommand.args[3]).toMatch(/Read \.gamestudio\/runs\/.+prompt\.md and perform the requested task\./);
expect(codexExecInvocation(projectRoot, result.promptPath, "codex-test").display).toContain("codex-test");
expect(() => prepareRun("qa_agent", { project: projectRoot, task: "x", exec: true, dryRun: true }, cwd)).toThrow(/--exec cannot be combined/);
});
test("can execute a codex-compatible binary with argument isolation", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-run-"));
const { projectRoot } = initProject({ name: "Stub Codex Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const stub = path.join(cwd, "codex-stub.mjs");
writeFileSync(stub, "#!/usr/bin/env node\nconsole.log(JSON.stringify(process.argv.slice(2)));\n");
chmodSync(stub, 0o755);
const result = prepareRun("qa_agent", { project: projectRoot, task: "Review validation readiness", exec: true, codexBin: stub }, cwd);
const execution = executeCodexRun(result);
expect(execution.status).toBe(0);
expect(JSON.parse(execution.stdout)).toEqual(result.codexCommand.args);
});
});
+20 -34
View File
@@ -3,7 +3,6 @@ import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { guidanceConfigHash, readProjectConfig, writeProjectConfig } from "../src/config.js";
import { freezeProject, initProject } from "../src/projects.js";
import { runValidation, validateProject } from "../src/validation.js";
@@ -30,26 +29,14 @@ describe("validation", () => {
expect(failures.map((f) => f.id)).toContain("project.engine_file");
});
test("malformed materialized agent prompts fail validation", () => {
test("malformed AGENTS contract and prompt files fail validation", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-"));
const { projectRoot, config } = initProject({ name: "Prompt Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
writeFileSync(
path.join(projectRoot, ".gamestudio", "agents", "master_orchestrator.md"),
`# Role\n\n# Inputs\n\n# Outputs\n\n# Validation\n\n# Engine Notes\n\n# Rules\n\n# Project Context\n\n- Name: ${config.project.name}\n- Engine: Godot ${config.project.engine_version}\n\n# Engine Overlay\n\nUse Godot.\n`
);
const { projectRoot } = initProject({ name: "Prompt Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
writeFileSync(path.join(projectRoot, "AGENTS.md"), "# Broken\n");
writeFileSync(path.join(projectRoot, ".codex", "prompts", "producer.md"), "");
const failures = validateProject(projectRoot).filter((c) => c.status === "fail");
expect(failures.map((f) => f.id)).toContain("project.agent.master_orchestrator");
});
test("materialized agent prompts fail validation when engine overlay is empty", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-"));
const { projectRoot, config } = initProject({ name: "Overlay Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
writeFileSync(
path.join(projectRoot, ".gamestudio", "agents", "master_orchestrator.md"),
`# Role\n\nCoordinate the team.\n\n# Inputs\n\n- Project brief.\n\n# Outputs\n\n- Production direction.\n\n# Validation\n\n- Check generated artifacts.\n\n# Engine Notes\n\n- Use engine-specific guidance.\n\n# Rules\n\n- Keep work scoped.\n\n# Project Context\n\n- Name: ${config.project.name}\n- Engine: Godot ${config.project.engine_version}\n\n# Engine Overlay\n\n`
);
const failures = validateProject(projectRoot).filter((c) => c.status === "fail");
expect(failures.map((f) => f.id)).toContain("project.agent.master_orchestrator");
expect(failures.map((f) => f.id)).toContain("codex.project.AGENTS.md.## Project Goal");
expect(failures.map((f) => f.id)).toContain("codex.prompt.producer");
});
test("empty timeline sections fail validation", () => {
@@ -71,31 +58,30 @@ describe("validation", () => {
expect(failures.map((f) => f.id)).toContain("project.engine_settings");
});
test("invalid config and stale AGENTS hash fail", () => {
test("invalid studio json fails", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-"));
const { projectRoot } = initProject({ name: "Stale Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const configPath = path.join(projectRoot, "project-config.json");
const config = readProjectConfig(configPath);
const hash = guidanceConfigHash(config);
config.project.genre = "Changed";
writeProjectConfig(configPath, config);
expect(validateProject(projectRoot).some((c) => c.id === "project.agents_md.hash" && c.status === "fail")).toBe(true);
config.project.status = "frozen";
expect(guidanceConfigHash(config)).not.toBe(hash);
writeFileSync(configPath, "{ invalid json");
writeFileSync(path.join(projectRoot, ".codex", "studio.json"), "{ invalid json");
expect(validateProject(projectRoot)[0].status).toBe("fail");
});
test("freeze status-only changes do not stale AGENTS hash", () => {
test("freeze status-only changes keep project validation green", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-"));
const { projectRoot } = initProject({ name: "Freeze Valid", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
freezeProject(projectRoot, cwd);
expect(validateProject(projectRoot).filter((c) => c.status === "fail")).toEqual([]);
});
test("repo validation fails hard when built CLI is missing", async () => {
const result = await runValidation();
expect(result.checks.some((c) => c.id === "package.bin")).toBe(true);
expect(result.failed).toBe(result.checks.some((c) => c.status === "fail"));
test("repo validation reports Codex readiness hard failure when unavailable", async () => {
const old = process.env.CODEX_BIN;
process.env.CODEX_BIN = "/missing/codex";
try {
const result = await runValidation();
expect(result.checks.some((c) => c.id === "codex.cli" && c.status === "fail")).toBe(true);
expect(result.failed).toBe(true);
} finally {
if (old === undefined) delete process.env.CODEX_BIN;
else process.env.CODEX_BIN = old;
}
});
});
+38
View File
@@ -0,0 +1,38 @@
import { writeFileSync, chmodSync } from "node:fs";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { runVerificationCommand } from "../src/verification.js";
describe("verification runner", () => {
test("runs structured argv with cwd and captures output", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-verify-"));
const script = path.join(cwd, "verify.mjs");
writeFileSync(script, "console.log(process.cwd()); console.error(process.argv.slice(2).join('|'));\n");
const result = await runVerificationCommand({ command: process.execPath, args: [script, "a b", "\"quoted\""] }, { cwd, timeoutMs: 1000 });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain(cwd);
expect(result.stderr).toContain("a b|\"quoted\"");
expect(result.timedOut).toBe(false);
});
test("reports nonzero, timeout, and bounded output", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-verify-"));
const fail = await runVerificationCommand({ command: process.execPath, args: ["-e", "process.exit(3)"] }, { cwd, timeoutMs: 1000 });
expect(fail.exitCode).toBe(3);
const timeout = await runVerificationCommand({ command: process.execPath, args: ["-e", "setTimeout(()=>{}, 5000)"] }, { cwd, timeoutMs: 50 });
expect(timeout.timedOut).toBe(true);
const noisy = await runVerificationCommand({ command: process.execPath, args: ["-e", "console.log('x'.repeat(1000))"] }, { cwd, timeoutMs: 1000, maxOutputBytes: 20 });
expect(noisy.stdout.length).toBeLessThanOrEqual(20);
});
test("does not require shell scripts", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-verify-"));
const script = path.join(cwd, "ok");
writeFileSync(script, "#!/usr/bin/env node\nconsole.log('ok')\n");
chmodSync(script, 0o755);
const result = await runVerificationCommand({ command: script, args: [] }, { cwd, timeoutMs: 1000 });
expect(result.stdout).toContain("ok");
});
});