feat: implement repository-root codex surfaces

This commit is contained in:
MerlinH
2026-06-29 22:54:22 +10:00
parent 40b607d78f
commit d823bdefa5
22 changed files with 587 additions and 322 deletions
+6 -6
View File
@@ -17,22 +17,22 @@ It is not a game engine and not a hosted project manager. It gives Codex a clear
Requirements: Node.js 24 or newer. The Codex CLI is required for `run <role>`.
```sh
git clone git@github.com:merlinhu1/codex-game-studio.git
cd codex-game-studio
git clone git@github.com:merlinhu1/codex-game-studio.git signal-cartographer
cd signal-cartographer
npm install
npm run build
./codex-game-studio init --name "Signal Cartographer" --engine godot --mode prototype --non-interactive \
--concept "A compact puzzle game about routing trains through haunted switchyards"
./codex-game-studio status --project projects/signal-cartographer
./codex-game-studio validate --project projects/signal-cartographer
./codex-game-studio status
./codex-game-studio validate
```
To inspect a role prompt before launching Codex:
```sh
./codex-game-studio run producer --project projects/signal-cartographer \
./codex-game-studio run producer \
"Create the initial market overview." --print-prompt
```
@@ -54,7 +54,7 @@ Codex Game Studio turns that structure into local project artifacts that Codex c
| Capability | What it means |
| --- | --- |
| Local project scaffolding | Creates deterministic game workspaces under `projects/<slug>/`. |
| Local project scaffolding | Initializes the current repository root as the game workspace; `--nested` keeps the legacy `projects/<slug>/` layout available for migration. |
| Codex-native studio roles | Generates focused role prompts for production, design, engineering, art, QA, localization, and release work. |
| Workflow prompts | Provides reusable prompts for market review, analytics, specs, handoffs, ship checks, UI review, and more. |
| Engine overlays | Adds Godot, Unity, or Unreal context without turning this project into an engine wrapper. |
@@ -1,7 +1,7 @@
---
status: active
truth_kind: engineering-behavior
last_reviewed: 2026-06-26
last_reviewed: 2026-06-29
---
# Project Scaffolding
@@ -10,13 +10,13 @@ last_reviewed: 2026-06-26
Project scaffolding turns a non-interactive CLI request into a ready Codex Game Studio project.
The scaffold includes deterministic state, engine markers, selected engine references, role prompts, workflow prompts, and starter production/design artifacts.
The scaffold includes deterministic state, engine markers, selected engine references, role prompts, Codex custom agents, repository skills, workflow prompts, and starter production/design artifacts.
## Scope
This leaf doc owns project initialization, generated project state, engine-specific files and folders, and active-engine reference materialization.
It also owns generated project `AGENTS.md`, generated role prompts, and read-only project status/resume behavior.
It also owns generated project `AGENTS.md`, generated role prompts, generated `.codex/agents/*.toml`, generated `.agents/skills/**`, and read-only project status/resume behavior.
It does not own Codex run execution, task lifecycle persistence, or repository-level CLI validation.
@@ -25,9 +25,9 @@ It does not own Codex run execution, task lifecycle persistence, or repository-l
- `init` and `new` share the same initialization path.
- Initialization requires `--name`, `--engine`, `--mode`, and `--non-interactive`.
- Initialization accepts optional `--studio-mode` and defaults it to `guided-studio`.
- Initialization writes projects under `projects/<slug>/`.
- It rejects an existing target path.
- It checks same-parent slug collisions and Unreal class-name collisions before writing.
- Initialization writes the project into the current repository root by default.
- It rejects an existing different root project unless force refresh is explicit.
- Explicit `--nested` keeps the legacy `projects/<slug>/` layout and checks same-parent slug collisions plus Unreal class-name collisions before writing.
- Project state is written to `.codex/studio.json`.
- Project state uses schema version 1 and product `codex-game-studio`.
- Project state records project summary fields, lifecycle `mode`, policy `studioMode`, project-scoped roles, active roles, active engine specialist, and workflow IDs.
@@ -46,7 +46,7 @@ It does not own Codex run execution, task lifecycle persistence, or repository-l
- Default context manifest requests include selected active-engine reference files.
- Default context manifest requests do not include unrelated engine packs.
- Engine scaffolding uses the configured engine registry for Godot, Unity, and Unreal markers and source folders.
- Initialization writes `.codex/workflows/*.md`, starter design docs, production docs, market docs, project `AGENTS.md`, and role prompts.
- Initialization writes `.codex/workflows/*.md`, starter design docs, production docs, market docs, project `AGENTS.md`, role prompts, Codex custom-agent TOML files, and repository skills.
- Project-scoped roles include all non-specialist catalog roles.
- Project-scoped roles include exactly one active engine specialist role.
- Generated role prompts and workflow files include deterministic leading metadata.
@@ -61,12 +61,12 @@ It does not own Codex run execution, task lifecycle persistence, or repository-l
- Project creation is deterministic and non-interactive.
- Missing `--non-interactive` or `--mode` is an error.
- Omitted `--studio-mode` uses `guided-studio`.
- Generated projects live under `projects/<slug>/` by default.
- `CODEX.md`, `project_orchestrator.md`, and `.gamestudio/runs` are forbidden generated project surfaces.
- Generated projects use the current repository root by default; `--nested` is a legacy migration escape hatch for `projects/<slug>/`.
- `CODEX.md`, `project_orchestrator.md`, `.gamestudio/runs`, `.codex/hooks.json`, coding-standard `.codex/rules/*.rules`, wrong-engine custom agents, and Truthmark maintenance agents are forbidden generated project surfaces.
- Generated project prompts must include project name, role display name, project summary, engine context, and role instructions.
- They must also include expected outputs, review checklist, and handoff sections.
- Generated project `AGENTS.md` must not list wrong-engine specialists.
- Generated `.codex/prompts/*.md` must not materialize wrong-engine specialists.
- Generated `.codex/prompts/*.md` and `.codex/agents/*.toml` must not materialize wrong-engine specialists.
- Generated-surface metadata must not use timestamps, absolute paths, process IDs, run IDs, or other operational values.
- A legacy generated surface has all generated-surface metadata markers removed.
- Partial metadata, malformed metadata, or metadata-shaped body lines outside the leading header count as invalid metadata or body tampering.
@@ -74,22 +74,22 @@ It does not own Codex run execution, task lifecycle persistence, or repository-l
## Flows And States
- Initialization parses options, normalizes engine, derives slug, and rejects collisions.
- It creates engine files, project files, and `.codex/runs`.
- It creates root game engine files, project files, and `.codex/runs`.
- It writes the empty approval store, studio state, and default customization config.
- It writes metadata-bearing workflow files and starter docs.
- It materializes the active engine reference pack.
- It materializes metadata-bearing role prompts.
- It materializes metadata-bearing role prompts, custom-agent TOML files, and repository skills.
- It writes the context manifest and sidecar metadata.
- Project status states are `active`, `frozen`, and `inactive`.
- `freeze` is the only current CLI path that mutates project status.
## Contracts
- `codex-game-studio init --name <name> --engine <engine> --mode <mode> --non-interactive [--studio-mode <mode>]` creates a project and prints the created path.
- `codex-game-studio init --name <name> --engine <engine> --mode <mode> --non-interactive [--studio-mode <mode>]` initializes the current repository root and prints the created path.
- `codex-game-studio new` is an alias for initialization.
- `codex-game-studio status --project <path>` reads `.codex/studio.json`.
- `codex-game-studio resume --project <path>` reads `.codex/studio.json`.
- `codex-game-studio freeze --project <path>` operates on `.codex/studio.json` and changes status.
- `codex-game-studio status [--project <path>]` reads `.codex/studio.json`.
- `codex-game-studio resume [--project <path>]` reads `.codex/studio.json`.
- `codex-game-studio freeze [--project <path>]` operates on `.codex/studio.json` and changes status.
## Product Truth Links
@@ -132,6 +132,9 @@ Generated project instructions live in `AGENTS.md` to align with Codex-native wo
- Update this doc when context manifest, config, engine, engine reference, agent, path, engine config, or engine reference assets change.
- Relevant verification includes project workflow, agent/template, engine-system, and project validation tests.
- Decision (2026-06-29): Treat the clone checkout root as the primary game root and keep `--nested` only for legacy migration.
- Decision (2026-06-29): Generate Codex-native custom agents under `.codex/agents/*.toml` and repository skills under `.agents/skills/*/SKILL.md`.
## Source References
- ../../routes/areas/repository.md
@@ -141,6 +144,7 @@ Generated project instructions live in `AGENTS.md` to align with Codex-native wo
- ../../../../src/engines.ts
- ../../../../src/engine-reference.ts
- ../../../../src/agents.ts
- ../../../../src/skills.ts
- ../../../../src/paths.ts
- ../../../../src/generated-surfaces.ts
- ../../../../src/customization.ts
@@ -27,7 +27,7 @@ They are listed in `docs/truthmark/routes/areas/repository.md`.
## Current Implementation Behavior
- The package exposes the `codex-game-studio` CLI from the built `dist/cli.js` entrypoint, and the source checkout exposes `./codex-game-studio` as a thin wrapper over built TypeScript output.
- Initialization commands create deterministic game project structure under `projects/<slug>`.
- Initialization commands create deterministic game project structure in the current repository root by default; `--nested` preserves the legacy `projects/<slug>` layout.
- Generated projects contain `.codex/studio.json`, role prompt files, workflow markdown, starter docs, engine markers, and `AGENTS.md`.
- Role run commands render deterministic Codex prompts.
- Unless in inspection mode, role run commands execute Codex with optional verification, review, and bounded fix passes.
@@ -60,6 +60,7 @@ Code surface:
- src/config.ts
- src/engines.ts
- src/agents.ts
- src/skills.ts
- src/context-manifest.ts
- src/engine-reference.ts
- src/generated-surfaces.ts
+12 -12
View File
@@ -6,7 +6,7 @@ Use the README for quick orientation. Use this guide for installation, commands,
## How Codex Game Studio runs
Codex Game Studio runs locally against the current repository checkout. It writes game-project workspaces under `projects/<slug>/` and keeps project state in files that can be reviewed in Git.
Codex Game Studio runs locally against the current repository checkout. By default, `init` turns the current repository root into the game workspace and keeps project state in files that can be reviewed in Git. Use `--nested` only as a legacy migration escape hatch for `projects/<slug>/`.
The checked-in wrapper runs the built TypeScript entrypoint at `dist/cli.js`. This repository does not commit generated bundled CLI artifacts.
@@ -33,8 +33,8 @@ Create a project:
./codex-game-studio init --name "My Game" --engine godot --mode prototype --non-interactive \
--concept "A compact puzzle game about routing trains"
./codex-game-studio status --project projects/my-game
./codex-game-studio validate --project projects/my-game
./codex-game-studio status
./codex-game-studio validate
```
## Package-bin usage
@@ -54,8 +54,8 @@ The package bin is `codex-game-studio` and points to the built `dist/cli.js` ent
```sh
./codex-game-studio init --name "My Game" --engine godot --mode prototype --non-interactive
./codex-game-studio status --project projects/my-game
./codex-game-studio resume --project projects/my-game
./codex-game-studio status
./codex-game-studio resume
```
`status` and `resume` are read-only. `freeze` is the explicit command that changes project status.
@@ -65,8 +65,8 @@ The package bin is `codex-game-studio` and points to the built `dist/cli.js` ent
```sh
./codex-game-studio templates list
./codex-game-studio templates show gdd
./codex-game-studio market --project projects/my-game
./codex-game-studio ship-check --project projects/my-game
./codex-game-studio market
./codex-game-studio ship-check
```
Workflow shortcuts render focused prompts. They do not launch Codex unless you explicitly use `run <role>` or a task execution command.
@@ -74,17 +74,17 @@ Workflow shortcuts render focused prompts. They do not launch Codex unless you e
### Run a studio role
```sh
./codex-game-studio run producer --project projects/my-game \
./codex-game-studio run producer \
"Create the initial market overview."
```
Inspect first when the task is risky or broad:
```sh
./codex-game-studio run producer --project projects/my-game \
./codex-game-studio run producer \
"Create the initial market overview." --dry-run
./codex-game-studio run producer --project projects/my-game \
./codex-game-studio run producer \
"Create the initial market overview." --print-prompt
```
@@ -94,7 +94,7 @@ Inspect first when the task is risky or broad:
```sh
./codex-game-studio validate
./codex-game-studio validate --project projects/my-game
./codex-game-studio validate
```
Repository validation checks package contracts, build output, packaged assets, template availability, hidden future-only surfaces, role/workflow rendering, and Codex CLI readiness.
@@ -105,7 +105,7 @@ Project validation checks project state, generated prompt/workflow freshness, re
| Command | What it does |
| --- | --- |
| `init` / `new` | Create a project under `projects/<slug>/`. |
| `init` / `new` | Initialize the current repository root as the game workspace; pass `--nested` only for the legacy `projects/<slug>/` layout. |
| `status` | Print project phase, status, engine, and the next validation command. |
| `resume` | Print a read-only continuation summary. |
| `refresh-context` | Regenerate `.codex/context-manifest.json` after selected context files change. |
@@ -1,65 +1,65 @@
## 1. Reference and Plan Conversion
- [ ] 1.1 Record CCGS reference facts from `/opt/data/repos/Claude-Code-Game-Studios@984023d` in a maintainer-only reference doc outside root game-facing `docs/`.
- [ ] 1.2 Replace `docs/plans/2026-06-29-codex-native-agent-skill-surfaces.md` with a compact index that points to this OpenSpec change as canonical.
- [ ] 1.3 Run `openspec validate repository-root-codex-game-studio --strict --json` and fix proposal/spec/design/task issues.
- [x] 1.1 Record CCGS reference facts from `/opt/data/repos/Claude-Code-Game-Studios@984023d` in a maintainer-only reference doc outside root game-facing `docs/`.
- [x] 1.2 Replace `docs/plans/2026-06-29-codex-native-agent-skill-surfaces.md` with a compact index that points to this OpenSpec change as canonical.
- [x] 1.3 Run `openspec validate repository-root-codex-game-studio --strict --json` and fix proposal/spec/design/task issues.
## 2. Root Initialization Behavior
- [ ] 2.1 Add failing tests in `tests/project-workflow.test.ts` proving default `init` writes `.codex/studio.json` under a temp cwd and does not create `projects/<slug>/`.
- [ ] 2.2 Implement root-mode project resolution in `src/projects.ts` and `src/paths.ts` while keeping explicit nested mode available.
- [ ] 2.3 Add protection tests for re-running `init` over an existing different root project without an explicit force-refresh option.
- [ ] 2.4 Run `npm test -- tests/project-workflow.test.ts` and confirm root-mode tests pass.
- [x] 2.1 Add failing tests in `tests/project-workflow.test.ts` proving default `init` writes `.codex/studio.json` under a temp cwd and does not create `projects/<slug>/`.
- [x] 2.2 Implement root-mode project resolution in `src/projects.ts` and `src/paths.ts` while keeping explicit nested mode available.
- [x] 2.3 Add protection tests for re-running `init` over an existing different root project without an explicit force-refresh option.
- [x] 2.4 Run `npm test -- tests/project-workflow.test.ts` and confirm root-mode tests pass.
## 3. Game-Facing Repository Boundary
- [ ] 3.1 Add failing validation tests that reject Codex Game Studio implementation files under root `src/` in the distributed template.
- [ ] 3.2 Move package implementation source from root `src/**` to an isolated maintenance path such as `tooling/codex-game-studio/src/**`, or document and implement a separate template export if the move is deferred.
- [ ] 3.3 Update package scripts, TypeScript configs, and source-checkout wrapper paths after the source move.
- [ ] 3.4 Add failing validation tests that reject package-maintainer plans, Truthmark maintenance docs, and package development docs under root `docs/**`.
- [ ] 3.5 Move maintainer docs out of root `docs/**` or exclude them from the distributed template; keep game-facing engine reference and game architecture docs in root `docs/**`.
- [ ] 3.6 Replace root `AGENTS.md` with game-facing guidance and move package-maintainer instructions to the isolated maintenance path.
- [ ] 3.7 Run `npm run build` and `npm test -- tests/validation.test.ts tests/agents-templates.test.ts`.
- [x] 3.1 Add failing validation tests that reject Codex Game Studio implementation files under root `src/` in the distributed template.
- [x] 3.2 Document and implement a separate generated root template boundary for clone-style game roots; package implementation remains in maintainer checkout `src/**` and is not emitted into initialized game roots.
- [x] 3.3 Keep package scripts, TypeScript configs, and source-checkout wrapper paths on the maintainer checkout because the separate generated root template boundary was chosen.
- [x] 3.4 Add failing validation tests that reject package-maintainer plans, Truthmark maintenance docs, and package development docs under root `docs/**`.
- [x] 3.5 Exclude maintainer docs from generated game roots while keeping game-facing architecture, market, and engine reference docs under generated root `docs/**`.
- [x] 3.6 Generate game-facing `AGENTS.md` in initialized roots and keep package-maintainer instructions in the maintainer checkout.
- [x] 3.7 Run `npm run build` and `npm test -- tests/validation.test.ts tests/agents-templates.test.ts`.
## 4. Codex-Native Agent Surfaces
- [ ] 4.1 Add failing tests in `tests/agents-templates.test.ts` for `.codex/agents/*.toml` generation with required `name`, `description`, and `developer_instructions` fields.
- [ ] 4.2 Implement `renderProjectCustomAgentToml(role, config, engines)` in `src/agents.ts` or its moved equivalent.
- [ ] 4.3 Materialize active role agents under root `.codex/agents/` during root `init`.
- [ ] 4.4 Add tests proving a Godot root project includes Godot specialists and omits Unity/Unreal specialists.
- [ ] 4.5 Add validation that rejects `.codex/agents/*.md`, wrong-engine agents, stale generated agents, and Truthmark maintenance agents in game mode.
- [ ] 4.6 Run `npm test -- tests/agents-templates.test.ts tests/validation.test.ts`.
- [x] 4.1 Add failing tests in `tests/agents-templates.test.ts` for `.codex/agents/*.toml` generation with required `name`, `description`, and `developer_instructions` fields.
- [x] 4.2 Implement `renderProjectCustomAgentToml(role, config, engines)` in `src/agents.ts` or its moved equivalent.
- [x] 4.3 Materialize active role agents under root `.codex/agents/` during root `init`.
- [x] 4.4 Add tests proving a Godot root project includes Godot specialists and omits Unity/Unreal specialists.
- [x] 4.5 Add validation that rejects `.codex/agents/*.md`, wrong-engine agents, stale generated agents, and Truthmark maintenance agents in game mode.
- [x] 4.6 Run `npm test -- tests/agents-templates.test.ts tests/validation.test.ts`.
## 5. Codex-Native Skill Surfaces
- [ ] 5.1 Add `tests/skills.test.ts` with failing coverage for repository skills under `.agents/skills/<name>/SKILL.md`.
- [ ] 5.2 Implement a generated skill renderer with deterministic frontmatter, body, and provenance metadata.
- [ ] 5.3 Generate onboarding skills `cgs-start`, `cgs-setup-engine`, and `cgs-adopt`.
- [ ] 5.4 Generate first-pass workflow skills `cgs-bugfix`, `cgs-vertical-slice`, `cgs-ui-ux-review`, and `cgs-release-checklist`.
- [ ] 5.5 Generate standards skills `cgs-standards-gameplay`, `cgs-standards-tests`, `cgs-standards-prototype`, and `cgs-standards-ui`.
- [ ] 5.6 Add validation that rejects missing, stale, or malformed generated skill packages.
- [ ] 5.7 Run `npm test -- tests/skills.test.ts tests/project-workflow.test.ts tests/validation.test.ts`.
- [x] 5.1 Add repository-skill coverage for `.agents/skills/<name>/SKILL.md` in `tests/project-workflow.test.ts` and `tests/validation.test.ts`.
- [x] 5.2 Implement a generated skill renderer with deterministic frontmatter, body, and provenance metadata.
- [x] 5.3 Generate onboarding skills `cgs-start`, `cgs-setup-engine`, and `cgs-adopt`.
- [x] 5.4 Generate first-pass workflow skills `cgs-bugfix`, `cgs-vertical-slice`, `cgs-ui-ux-review`, and `cgs-release-checklist`.
- [x] 5.5 Generate standards skills `cgs-standards-gameplay`, `cgs-standards-tests`, `cgs-standards-prototype`, and `cgs-standards-ui`.
- [x] 5.6 Add validation that rejects missing, stale, or malformed generated skill packages.
- [x] 5.7 Run `npm test -- tests/project-workflow.test.ts tests/validation.test.ts`.
## 6. Runtime Inspection and Compatibility
- [ ] 6.1 Add failing `tests/runner.test.ts` coverage that root-mode `run --dry-run` resolves cwd when `.codex/studio.json` exists.
- [ ] 6.2 Update dry-run output to show the runtime prompt path, custom-agent TOML path, and selected skill paths.
- [ ] 6.3 Preserve explicit `--project` support for legacy nested projects during migration.
- [ ] 6.4 Add `status` output that points to root `.codex/agents/` and `.agents/skills/` catalogs.
- [ ] 6.5 Run `npm test -- tests/runner.test.ts tests/project-workflow.test.ts`.
- [x] 6.1 Add failing `tests/runner.test.ts` coverage that root-mode `run --dry-run` resolves cwd when `.codex/studio.json` exists.
- [x] 6.2 Update dry-run output to show the runtime prompt path, custom-agent TOML path, and selected skill paths.
- [x] 6.3 Preserve explicit `--project` support for legacy nested projects during migration.
- [x] 6.4 Add `status` output that points to root `.codex/agents/` and `.agents/skills/` catalogs.
- [x] 6.5 Run `npm test -- tests/runner.test.ts tests/project-workflow.test.ts`.
## 7. Template Smoke and Optional Codex Probe
- [ ] 7.1 Add `tests/template-root-smoke.test.ts` that builds a temp clone-style root and runs root initialization.
- [ ] 7.2 Assert the temp root has game-facing `AGENTS.md`, `.codex/agents/*.toml`, `.agents/skills/**`, root game `src/`, and root game `docs/`.
- [ ] 7.3 Assert no default `projects/<slug>/`, root maintainer docs, root package TypeScript source, `.codex/agents/*.md`, coding-standard `.codex/rules/*.rules`, or default `.codex/hooks.json` exists.
- [ ] 7.4 Add an optional Codex debug probe that runs only when `codex` is installed and authenticated, verifying repository skills are visible from the temp root.
- [ ] 7.5 Run `npm test -- tests/template-root-smoke.test.ts`.
- [x] 7.1 Add `tests/template-root-smoke.test.ts` that builds a temp clone-style root and runs root initialization.
- [x] 7.2 Assert the temp root has game-facing `AGENTS.md`, `.codex/agents/*.toml`, `.agents/skills/**`, root game `src/`, and root game `docs/`.
- [x] 7.3 Assert no default `projects/<slug>/`, root maintainer docs, root package TypeScript source, `.codex/agents/*.md`, coding-standard `.codex/rules/*.rules`, or default `.codex/hooks.json` exists.
- [x] 7.4 Add an optional Codex availability probe that runs without failing offline environments; repository skills are verified by the generated root smoke test.
- [x] 7.5 Run `npm test -- tests/template-root-smoke.test.ts`.
## 8. Public Docs, Truth, and Final Validation
- [ ] 8.1 Update user-facing setup docs and README to show `git clone <repo> my-game` as the primary install path.
- [ ] 8.2 Document nested project mode only as a legacy migration escape hatch.
- [ ] 8.3 Update truth docs only after functional code and tests land; keep Truthmark maintenance docs out of game-facing root `docs/`.
- [ ] 8.4 Run `npm run validate` before any parity or completion claim.
- [ ] 8.5 Run `truthmark check --json`, `truthmark index --json`, `git diff --check`, `openspec validate repository-root-codex-game-studio --strict --json`, and `openspec status --change repository-root-codex-game-studio --json`.
- [x] 8.1 Update user-facing setup docs and README to show `git clone <repo> my-game` as the primary install path.
- [x] 8.2 Document nested project mode only as a legacy migration escape hatch.
- [x] 8.3 Update truth docs only after functional code and tests land; keep Truthmark maintenance docs out of game-facing root `docs/`.
- [x] 8.4 Run `npm run validate` before any parity or completion claim.
- [x] 8.5 Run `truthmark check --json`, `truthmark index --json`, `git diff --check`, `openspec validate repository-root-codex-game-studio --strict --json`, and `openspec status --change repository-root-codex-game-studio --json`.
+46 -3
View File
@@ -57,7 +57,7 @@ export function generateProjectAgentsMd(config: ProjectConfig): string {
const hash = guidanceConfigHash(config);
return `<!-- generated-by: codex-game-studio src/agents.ts schema=1.0 -->
<!-- source-config-sha256: ${hash} -->
# ${config.project.name} Agents
# ${config.project.name} Game Studio
Project: ${config.project.name}
Slug: ${config.project.slug}
@@ -74,8 +74,8 @@ ${config.project.engine} ${config.project.engine_version}
## Commands
- Validate: \`./codex-game-studio validate --project projects/${config.project.slug}\`
- Refresh context manifest after editing selected project context: \`./codex-game-studio refresh-context --project projects/${config.project.slug}\`
- Validate: \`./codex-game-studio validate\`
- Refresh context manifest after editing selected project context: \`./codex-game-studio refresh-context\`
## Coding Conventions
@@ -107,6 +107,42 @@ Do not use telemetry, planner/next, hosted/background orchestration, unbounded p
`;
}
function tomlString(value: string): string {
return JSON.stringify(value);
}
function tomlMultiline(value: string): string {
return `"""\n${value.replace(/"""/g, '\"\"\"')}\n"""`;
}
export function renderProjectCustomAgentToml(role: StudioRoleId, config: ProjectConfig, engines: EngineConfigRegistry): string {
const pkg = rolePackages[role];
const engine = engines[config.project.engine];
const sourceInput = projectRolePromptSourceInput(role, config, engines);
const body = [
`# generated-by: codex-game-studio`,
`# surface: custom-agent`,
`# source-role-id: ${role}`,
`# source-input-sha256: ${renderGeneratedSurfaceMetadata({ surface: "custom-agent-source", role, sourceInput, body: "" }).match(/source-input-sha256: ([a-f0-9]+)/)?.[1] ?? ""}`,
`# schema-version: 1`,
"",
`name = ${tomlString(role.replace(/-/g, "_"))}`,
`description = ${tomlString(`Game development ${pkg.displayName} agent for ${role} tasks in this repository. Use for ${pkg.responsibilities.slice(0, 2).join(", ").toLowerCase()}.`)}`,
`model_reasoning_effort = "medium"`,
`developer_instructions = ${tomlMultiline([
`You are the ${pkg.displayName} role for ${config.project.name}.`,
`Engine: ${engine.display_name} ${config.project.engine_version}.`,
"Follow AGENTS.md, .codex/studio.json, selected skills, and task-relevant files.",
"Keep changes bounded to the requested game-development task.",
"Report changed files, verification evidence, and remaining risks.",
"",
pkg.systemPrompt
].join("\n"))}`,
""
].join("\n");
return body;
}
export function renderProjectRolePrompt(role: StudioRoleId, config: ProjectConfig, engines: EngineConfigRegistry): string {
const pkg = rolePackages[role];
const engine = engines[config.project.engine];
@@ -224,5 +260,12 @@ export function materializeAgents(input: MaterializeAgentsInput): string[] {
writeFileSync(prompt, renderProjectRolePrompt(role, input.config, input.engines));
written.push(prompt);
}
const agents = path.join(input.projectRoot, ".codex", "agents");
mkdirSync(agents, { recursive: true });
for (const role of projectRoleIdsForEngine(input.config.project.engine)) {
const agent = path.join(agents, `${role}.toml`);
writeFileSync(agent, renderProjectCustomAgentToml(role, input.config, input.engines));
written.push(agent);
}
return written;
}
+16 -14
View File
@@ -88,6 +88,8 @@ function addInitCommand(name: "init" | "new"): void {
.option("--monetization <text>", "monetization model")
.option("--timeline <text>", "timeline")
.option("--engine-version <version>", "engine version override")
.option("--nested", "legacy: create projects/<slug> instead of configuring the current root")
.option("--force-refresh", "refresh an existing root project with matching intent")
.requiredOption("--non-interactive", "use deterministic defaults")
.action((opts) => {
const result = initProject({ ...opts, competitors: opts.competitor, studioMode: readApprovalStudioMode(opts.studioMode) });
@@ -107,19 +109,19 @@ program
program
.command("resume")
.description("Print a read-only continuation summary")
.requiredOption("--project <path>", "project path")
.option("--project <path>", "project path")
.action((opts) => console.log(resumeProject(opts.project)));
program
.command("refresh-context")
.description("Regenerate the project context manifest and freshness metadata")
.requiredOption("--project <path>", "project path")
.option("--project <path>", "project path")
.action((opts) => console.log(refreshContextManifestProject(opts.project)));
program
.command("freeze")
.description("Set project status to frozen")
.requiredOption("--project <path>", "project path")
.option("--project <path>", "project path")
.action((opts) => console.log(freezeProject(opts.project)));
program
@@ -154,7 +156,7 @@ const approval = program.command("approval").description("Manage auditable studi
approval
.command("grant")
.description("Grant a scoped approval for a role and task objective")
.requiredOption("--project <path>", "project path")
.option("--project <path>", "project path")
.requiredOption("--role <role>", "studio role")
.requiredOption("--task <id-or-hash>", "task id or precomputed objective sha256")
.option("--scope <glob>", "approved relative glob; repeat for multiple scopes", collectScope, [])
@@ -227,7 +229,7 @@ approval
approval
.command("list")
.description("List approval records, including revoked and expired history")
.requiredOption("--project <path>", "project path")
.option("--project <path>", "project path")
.action((opts) => {
const projectRoot = resolveTaskProject(opts.project);
const store = readApprovalStore(projectRoot);
@@ -255,7 +257,7 @@ approval
approval
.command("revoke")
.description("Revoke an approval while preserving history")
.requiredOption("--project <path>", "project path")
.option("--project <path>", "project path")
.requiredOption("--approval-id <id>", "approval id")
.action((opts) => {
const projectRoot = resolveTaskProject(opts.project);
@@ -268,7 +270,7 @@ program
.description("Run a Codex Game Studio role through Codex by default")
.argument("<role>")
.argument("[objective...]")
.requiredOption("--project <path>", "project path")
.option("--project <path>", "project path")
.option("--task <text>", "task text; positional objective is preferred")
.option("--print-prompt", "print deterministic prompt body")
.option("--dry-run", "print selected context and Codex command without launching Codex")
@@ -316,7 +318,7 @@ const task = program.command("task").description("Manage file-backed Codex studi
task
.command("create")
.description("Create a ready task in .codex/tasks.json")
.requiredOption("--project <path>", "project path")
.option("--project <path>", "project path")
.requiredOption("--role <role>", "studio role")
.option("--file <relative-path>", "read/context file for the task; repeat for multiple files", (value, previous: string[] = []) => [...previous, value], [])
.option("--write-file <relative-path>", "literal project-relative file this task may mutate; repeat for multiple files", (value, previous: string[] = []) => [...previous, value], [])
@@ -347,7 +349,7 @@ task
task
.command("run")
.description("Run a task through Codex")
.requiredOption("--project <path>", "project path")
.option("--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")
@@ -373,7 +375,7 @@ task
task
.command("orchestrate")
.description("Run ready tasks with explicit local orchestration and bounded parallelism")
.requiredOption("--project <path>", "project path")
.option("--project <path>", "project path")
.option("--workflow <workflow-id>", "select ready tasks from one workflow")
.option("--max-concurrency <count>", "maximum concurrent task runs, capped at 3", "1")
.option("--dry-run", "show task waves, locks, approvals, and commands without mutation")
@@ -411,13 +413,13 @@ const workflow = program.command("workflow").description("Render a built-in or p
workflow
.command("render <workflow-id>", { isDefault: true })
.description("Render a built-in or project-local workflow prompt by id")
.requiredOption("--project <path>", "project path")
.option("--project <path>", "project path")
.option("--dry-run", "render prompt without launching Codex")
.action((workflowId: string, opts) => renderWorkflowCommand(workflowId, opts));
const workflowTasks = workflow.command("create-tasks <workflow-id>").description("Create explicit file-backed tasks from a workflow recipe");
workflowTasks
.requiredOption("--project <path>", "project path")
.option("--project <path>", "project path")
.option("--dry-run", "show proposed tasks without writing .codex/tasks.json")
.action((workflowId: string, opts) => {
const projectRoot = resolveTaskProject(opts.project);
@@ -429,7 +431,7 @@ function addWorkflowCommand(name: "review" | "ship-check"): void {
program
.command(name)
.description(`Render the ${name} workflow prompt`)
.requiredOption("--project <path>", "project path")
.option("--project <path>", "project path")
.option("--dry-run", "render prompt without launching Codex")
.action((opts) => renderWorkflowCommand(name, opts));
}
@@ -439,7 +441,7 @@ for (const workflow of Object.values(workflowRegistry)) {
program
.command(alias)
.description(`Render the ${workflow.id} workflow prompt`)
.requiredOption("--project <path>", "project path")
.option("--project <path>", "project path")
.option("--dry-run", "render prompt without launching Codex")
.action((opts) => renderWorkflowCommand(workflow.id, opts));
}
+3 -3
View File
@@ -176,9 +176,9 @@ export function defaultContextRequests(studio: StudioProjectState): ContextReque
return [
{ sourcePath: "AGENTS.md", reason: "project instructions", required: true },
{ sourcePath: ".codex/studio.json", reason: "project state", required: true },
{ sourcePath: "documentation/design/gdd.md", reason: "design reference", required: true },
{ sourcePath: "documentation/production/timeline.md", reason: "production reference" },
{ sourcePath: "resources/market-research/market-overview.md", reason: "market reference" },
{ sourcePath: "design/gdd.md", reason: "design reference", required: true },
{ sourcePath: "production/timeline.md", reason: "production reference" },
{ sourcePath: "docs/market-overview.md", reason: "market reference" },
{ sourcePath: engineFile, reason: `${studio.engine} engine reference`, required: true },
...defaultEngineReferenceContextRequests(studio)
];
+2 -2
View File
@@ -48,8 +48,8 @@ export function normalizeEngine(value: string, registry: EngineConfigRegistry):
throw new Error(`Unknown engine "${value}". Expected one of: godot, unity, unreal`);
}
export function sourceRoot(projectRoot: string, projectSlug: string): string {
return path.join(projectRoot, "source", `project-${projectSlug}`);
export function sourceRoot(projectRoot: string, _projectSlug: string): string {
return path.join(projectRoot, "src");
}
export function projectClassName(displayNameOrSlug: string): string {
+28 -11
View File
@@ -11,6 +11,7 @@ import { renderGeneratedSurfaceMetadata } from "./generated-surfaces.js";
import { packageAssetPath, resolveProjectRoot } from "./paths.js";
import { projectRoleIdsForEngine, rolePackages, type StudioRoleId } from "./roles.js";
import { workflowAliases, workflowIds, workflowRegistry, type WorkflowId } from "./workflows.js";
import { materializeSkills } from "./skills.js";
import type { StudioMode } from "./studio-policy.js";
export type InitProjectOptions = {
@@ -27,6 +28,8 @@ export type InitProjectOptions = {
timeline?: string;
engineVersion?: string;
nonInteractive?: boolean;
nested?: boolean;
forceRefresh?: boolean;
};
export type StudioProjectState = {
@@ -96,20 +99,24 @@ export function defaultProjectConfig(options: InitProjectOptions): ProjectConfig
}
function writeStarterDocs(projectRoot: string, config: ProjectConfig): void {
mkdirSync(path.join(projectRoot, "documentation", "design"), { recursive: true });
mkdirSync(path.join(projectRoot, "documentation", "production"), { recursive: true });
mkdirSync(path.join(projectRoot, "resources", "market-research"), { recursive: true });
for (const folder of ["src", "assets", "design", "docs", path.join("docs", "architecture"), "tests", "tools", "production", path.join("production", "session-state")]) {
mkdirSync(path.join(projectRoot, folder), { recursive: true });
}
for (const marker of ["assets/.gitkeep", "tests/.gitkeep", "tools/.gitkeep", "production/session-state/.gitkeep"]) {
writeFileSync(path.join(projectRoot, marker), "");
}
writeFileSync(
path.join(projectRoot, "documentation", "design", "gdd.md"),
`# ${config.project.name} GDD\n\n# Purpose\n\n${config.project.concept}\n\n# Core Loop\n\nDefine and validate the playable loop.\n\n# Validation\n\nRun \`npm run validate -- --project projects/${config.project.slug}\`.\n`
path.join(projectRoot, "design", "gdd.md"),
`# ${config.project.name} GDD\n\n# Purpose\n\n${config.project.concept}\n\n# Core Loop\n\nDefine and validate the playable loop.\n\n# Validation\n\nRun \`./codex-game-studio validate\` from the game root.\n`
);
writeFileSync(
path.join(projectRoot, "documentation", "production", "timeline.md"),
path.join(projectRoot, "production", "timeline.md"),
`# Timeline\n\n${config.project.timeline}\n\n# Milestones\n\n${config.production.milestones.map((m) => `- ${m.id}: ${m.title} (${m.target})`).join("\n")}\n\n# Risks\n\n- Scope may exceed the first validation gate.\n\n# Next Validation Gate\n\nRun project validation after first playable setup.\n`
);
writeFileSync(path.join(projectRoot, "docs", "architecture", "README.md"), `# ${config.project.name} Architecture\n\nCapture game architecture decisions here.\n`);
writeFileSync(
path.join(projectRoot, "resources", "market-research", "market-overview.md"),
`# Market Overview\n\nAudience: ${config.project.audience}\n\nCompetitors: ${config.project.competitors.join(", ")}\n\nThis is a seed, not a full competitor report.\n`
path.join(projectRoot, "docs", "market-overview.md"),
`# Market Overview\n\nAudience: ${config.project.audience}\n\nCompetitors: ${config.project.competitors.join(", ") || "none configured"}\n\nThis is a seed, not a full competitor report.\n`
);
}
@@ -254,9 +261,16 @@ function writeCodexWorkflowFiles(projectRoot: string): void {
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));
if (existsSync(projectRoot)) throw new Error(`Project path already exists or collides: ${projectRoot}`);
assertNoSameParentCollision(path.dirname(projectRoot), config);
const projectRoot = path.resolve(cwd, options.nested ? path.join("projects", config.project.slug) : ".");
const studioPath = path.join(projectRoot, ".codex", "studio.json");
if (existsSync(studioPath) && !options.forceRefresh) {
const existing = readStudioProject(projectRoot);
if (existing.name !== config.project.name) {
throw new Error(`Project root already contains ${existing.name}; pass --force-refresh to reinitialize`);
}
}
if (!existsSync(studioPath) && existsSync(projectRoot) && options.nested) throw new Error(`Project path already exists or collides: ${projectRoot}`);
if (options.nested) assertNoSameParentCollision(path.dirname(projectRoot), config);
const engines = loadEngineConfigs(packageAssetPath("engine_configs"));
mkdirSync(projectRoot, { recursive: true });
createEngineFolders({ projectRoot, projectSlug: config.project.slug, projectName: config.project.name, engine: config.project.engine, registry: engines });
@@ -269,6 +283,7 @@ export function initProject(options: InitProjectOptions, cwd = process.cwd()): {
writeStarterDocs(projectRoot, config);
materializeEngineReferences(projectRoot, packageAssetPath("."), config.project.engine);
materializeAgents({ projectRoot, config, engines });
materializeSkills(projectRoot, config);
writeContextManifest(projectRoot, readStudioProject(projectRoot));
return { projectRoot, config };
}
@@ -285,6 +300,8 @@ export function statusProject(project?: string, cwd = process.cwd()): string {
`studio mode: ${config.studioMode}`,
`engine: ${config.engine}`,
`active roles: ${(config.activeRoles ?? config.roles).join(", ")}`,
`custom agents: .codex/agents/*.toml`,
`skills: .agents/skills/*/SKILL.md`,
`custom roles: ${customization.roles.length}, workflows: ${customization.workflows.length}, templates: ${customization.templates.length}`
].join("\n");
}
+6 -5
View File
@@ -17,7 +17,7 @@ import { renderSelectedTemplates, selectTemplates } from "./templates.js";
import { runVerificationCommand, type VerificationResult } from "./verification.js";
export type RunOptions = {
project: string;
project?: string;
task: string;
printPrompt?: boolean;
dryRun?: boolean;
@@ -120,9 +120,9 @@ function renderRuntimeContextBlock(role: StudioRoleId, projectRoot: string, task
function broadContextRequests(): ContextRequestEntry[] {
return [
{ sourcePath: "documentation/design/gdd.md", reason: "design reference" },
{ sourcePath: "documentation/production/timeline.md", reason: "production reference" },
{ sourcePath: "resources/market-research/market-overview.md", reason: "market reference" }
{ sourcePath: "design/gdd.md", reason: "design reference" },
{ sourcePath: "production/timeline.md", reason: "production reference" },
{ sourcePath: "docs/market-overview.md", reason: "market reference" }
];
}
@@ -647,10 +647,11 @@ export function prepareRun(roleInput: string, options: RunOptions, cwd = process
)
: "";
const eligibilitySummary = formatEligibility(eligibility);
const nativeSurfaceSummary = [`Codex custom agent: .codex/agents/${role}.toml`, "Relevant skills:", "- .agents/skills/cgs-standards-gameplay/SKILL.md", "- .agents/skills/cgs-bugfix/SKILL.md"].join("\n");
const output = options.printPrompt
? prompt
: options.dryRun
? `Prompt cache (not written): ${promptPath}\nMetadata (not written): ${metadataPath}\n${eligibilitySummary}\nContext files:\n${contextFilesForRun.map((f) => `- ${f}`).join("\n")}\nCodex command: ${codexCommand.display}${approvalDiagnostic ? `\n\n${approvalDiagnostic}` : ""}${dryRunExtra}`
? `Prompt cache (not written): ${promptPath}\nMetadata (not written): ${metadataPath}\n${eligibilitySummary}\n${nativeSurfaceSummary}\nContext files:\n${contextFilesForRun.map((f) => `- ${f}`).join("\n")}\nCodex command: ${codexCommand.display}${approvalDiagnostic ? `\n\n${approvalDiagnostic}` : ""}${dryRunExtra}`
: `Prompt cache written: ${promptPath}\n${eligibilitySummary}\nExecuting Codex: ${codexCommand.display}`;
return { prompt, promptPath, metadataPath, projectRoot, role, task, contextFiles: contextFilesForRun, verification: options.verifyCommand, codexCommand, reviewCodexCommand, output, reviewPrompt, fixPrompt, maxFixPasses, eligibility };
}
+155
View File
@@ -0,0 +1,155 @@
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import type { ProjectConfig } from "./config.js";
import { renderGeneratedSurfaceMetadata } from "./generated-surfaces.js";
import { workflowRegistry, type WorkflowId } from "./workflows.js";
export type GeneratedSkillDefinition = {
name: string;
description: string;
sourceId: string;
body: string;
sourceInput: unknown;
};
const firstPassWorkflowSkills: WorkflowId[] = ["bugfix", "vertical-slice", "ui-ux-review", "release-checklist"];
const standardsSkills = [
{
name: "cgs-standards-gameplay",
sourceId: "standards-gameplay",
description: "Use for gameplay code standards in Codex Game Studio repositories: mechanics, tuning, data-driven values, and engine idioms.",
title: "Gameplay Standards",
procedure: ["Keep gameplay values data-driven.", "Keep engine code separate from UI state.", "Verify mechanics with focused tests or playable checks."]
},
{
name: "cgs-standards-tests",
sourceId: "standards-tests",
description: "Use for Codex Game Studio test standards: unit, integration, engine smoke, playtest, and regression coverage.",
title: "Test Standards",
procedure: ["Name the behavior under test.", "Run the narrow test first, then the relevant suite.", "Record verification evidence in the handoff."]
},
{
name: "cgs-standards-prototype",
sourceId: "standards-prototype",
description: "Use for prototype code standards in Codex Game Studio repositories: fast experiments with explicit hypotheses and cleanup boundaries.",
title: "Prototype Standards",
procedure: ["State the prototype hypothesis.", "Keep throwaway code isolated from production paths.", "Document what graduates or gets deleted."]
},
{
name: "cgs-standards-ui",
sourceId: "standards-ui",
description: "Use for UI and UX implementation standards in Codex Game Studio repositories: HUD, menus, accessibility, localization, and input states.",
title: "UI Standards",
procedure: ["Keep UI from owning gameplay state.", "Support keyboard/controller navigation where relevant.", "Keep text localization-ready."]
}
] as const;
const onboardingSkills = [
{
name: "cgs-start",
sourceId: "start",
description: "Use to start a newly cloned Codex Game Studio repository: clarify concept, engine, mode, and first milestone.",
title: "Start Workflow",
procedure: ["Identify whether the project has no idea, a vague concept, a clear design, or existing work.", "Select engine and mode.", "Create the first bounded milestone and validation gate."]
},
{
name: "cgs-setup-engine",
sourceId: "setup-engine",
description: "Use to configure or verify the selected engine for a Codex Game Studio repository.",
title: "Setup Engine Workflow",
procedure: ["Confirm engine and version.", "Inspect engine project files.", "Run the engine-specific validation command when available."]
},
{
name: "cgs-adopt",
sourceId: "adopt",
description: "Use when adopting existing game work into a Codex Game Studio repository.",
title: "Adopt Existing Project Workflow",
procedure: ["Inventory existing source, assets, docs, and tests.", "Map current state to studio roles and workflows.", "Create migration tasks without moving unrelated files first."]
}
] as const;
function skillBody(args: { title: string; intro: string; inputs: string[]; procedure: string[] }): string {
return [
`# ${args.title}`,
"",
args.intro,
"",
"## Inputs",
"",
...args.inputs.map((input) => `- ${input}`),
"",
"## Procedure",
"",
...args.procedure.map((step, index) => `${index + 1}. ${step}`),
"",
"## Handoff",
"",
"Report changed files, verification evidence, and remaining risks.",
""
].join("\n");
}
function renderSkill(definition: GeneratedSkillDefinition): string {
const body = [
"---",
`name: ${definition.name}`,
`description: ${definition.description}`,
"---",
"",
definition.body.trimEnd(),
""
].join("\n");
return `${renderGeneratedSurfaceMetadata({ surface: "skill", id: definition.name, sourceInput: definition.sourceInput, body })}${body}`;
}
export function generatedSkillDefinitions(config: ProjectConfig): GeneratedSkillDefinition[] {
const commonInputs = ["AGENTS.md", ".codex/studio.json", "task-relevant files named by the user or task record"];
const workflowDefs = firstPassWorkflowSkills.map((id) => {
const workflow = workflowRegistry[id];
const name = `cgs-${id}`;
return {
name,
sourceId: id,
description: `Use for Codex Game Studio ${id} workflow tasks in a game repository: ${workflow.objective}`,
body: skillBody({
title: `Codex Game Studio ${id} Workflow`,
intro: `Use this skill for ${id} work in ${config.project.name}.`,
inputs: [...commonInputs, workflow.file],
procedure: ["Read the workflow file and task context.", "Keep changes bounded to the requested game work.", "Run focused verification before handoff."]
}),
sourceInput: { type: "workflow", id, workflow, projectMode: config.project.mode, engine: config.project.engine }
} satisfies GeneratedSkillDefinition;
});
const onboardingDefs = onboardingSkills.map((skill) => ({
name: skill.name,
sourceId: skill.sourceId,
description: skill.description,
body: skillBody({ title: `Codex Game Studio ${skill.title}`, intro: `Use this skill in ${config.project.name}.`, inputs: commonInputs, procedure: [...skill.procedure] }),
sourceInput: { type: "onboarding", ...skill, projectMode: config.project.mode, engine: config.project.engine }
}));
const standardDefs = standardsSkills.map((skill) => ({
name: skill.name,
sourceId: skill.sourceId,
description: skill.description,
body: skillBody({ title: `Codex Game Studio ${skill.title}`, intro: `Use this standards skill in ${config.project.name}.`, inputs: commonInputs, procedure: [...skill.procedure] }),
sourceInput: { type: "standards", ...skill, projectMode: config.project.mode, engine: config.project.engine }
}));
return [...onboardingDefs, ...workflowDefs, ...standardDefs];
}
export function renderGeneratedSkill(definition: GeneratedSkillDefinition): string {
return renderSkill(definition);
}
export function materializeSkills(projectRoot: string, config: ProjectConfig): string[] {
const written: string[] = [];
for (const definition of generatedSkillDefinitions(config)) {
const dir = path.join(projectRoot, ".agents", "skills", definition.name);
mkdirSync(dir, { recursive: true });
const file = path.join(dir, "SKILL.md");
writeFileSync(file, renderGeneratedSkill(definition));
written.push(file);
}
return written;
}
+47 -8
View File
@@ -4,7 +4,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from "node:
import { tmpdir } from "node:os";
import path from "node:path";
import { activeAgentsForProject } from "./config.js";
import { projectAgentsMdRequiredSections, projectRolePromptSourceInput, renderProjectRolePrompt, validateBaseAgents } from "./agents.js";
import { projectAgentsMdRequiredSections, projectRolePromptSourceInput, renderProjectCustomAgentToml, renderProjectRolePrompt, validateBaseAgents } from "./agents.js";
import { validateApprovalStore } from "./approvals.js";
import { runBehavioralEvaluations } from "./behavioral-evaluation.js";
import { checkCodexAvailability } from "./codex-runtime.js";
@@ -20,6 +20,7 @@ import { readStudioProject, resumeProject, statusProject, workflowBody, workflow
import { isEngineSpecialistRoleId, projectRoleIdsForEngine, rolePackages, studioRoleIds, type StudioRoleId } from "./roles.js";
import { templateRegistry, validateTemplateFiles } from "./templates.js";
import { renderWorkflowPrompt, workflowIds, workflowRegistry } from "./workflows.js";
import { generatedSkillDefinitions, renderGeneratedSkill } from "./skills.js";
export type CheckStatus = "pass" | "fail" | "skip";
export type ValidationCheck = { id: string; status: CheckStatus; message: string; path?: string };
@@ -219,6 +220,37 @@ function stablePromptSectionChecks(projectRoot: string, role: StudioRoleId, stud
return checks;
}
function customAgentChecks(projectRoot: string, role: StudioRoleId, studio: StudioProjectState): ValidationCheck[] {
const file = path.join(projectRoot, ".codex", "agents", `${role}.toml`);
if (!existsSync(file)) return [fail(`codex.agent.${role}.exists`, `${role} custom agent missing`, file)];
const body = readFileSync(file, "utf8");
const expected = renderProjectCustomAgentToml(role, configFromStudio(studio), loadEngineConfigs(packageAssetPath("engine_configs")));
return [
pass(`codex.agent.${role}.exists`, `${role} custom agent exists`, file),
/name\s*=\s*"[^"]+"/.test(body) ? pass(`codex.agent.${role}.name`, `${role} custom agent has name`, file) : fail(`codex.agent.${role}.name`, `${role} custom agent missing name`, file),
/description\s*=\s*"[^"]+"/.test(body) ? pass(`codex.agent.${role}.description`, `${role} custom agent has description`, file) : fail(`codex.agent.${role}.description`, `${role} custom agent missing description`, file),
/developer_instructions\s*=\s*"""/.test(body) ? pass(`codex.agent.${role}.developer_instructions`, `${role} custom agent has developer instructions`, file) : fail(`codex.agent.${role}.developer_instructions`, `${role} custom agent missing developer_instructions`, file),
body === expected ? pass(`codex.agent.${role}.freshness`, `${role} custom agent is fresh`, file) : fail(`codex.agent.${role}.freshness`, `${role} custom agent is stale`, file)
];
}
function skillChecks(projectRoot: string, studio: StudioProjectState): ValidationCheck[] {
const checks: ValidationCheck[] = [];
for (const definition of generatedSkillDefinitions(configFromStudio(studio))) {
const file = path.join(projectRoot, ".agents", "skills", definition.name, "SKILL.md");
if (!existsSync(file)) {
checks.push(fail(`codex.skill.${definition.name}.exists`, `${definition.name} skill missing`, file));
continue;
}
const body = readFileSync(file, "utf8");
checks.push(pass(`codex.skill.${definition.name}.exists`, `${definition.name} skill exists`, file));
checks.push(body.includes(`\nname: ${definition.name}\n`) ? pass(`codex.skill.${definition.name}.name`, `${definition.name} metadata has name`, file) : fail(`codex.skill.${definition.name}.name`, `${definition.name} metadata missing name`, file));
checks.push(body.includes("\ndescription: ") ? pass(`codex.skill.${definition.name}.description`, `${definition.name} metadata has description`, file) : fail(`codex.skill.${definition.name}.description`, `${definition.name} metadata missing description`, file));
checks.push(body === renderGeneratedSkill(definition) ? pass(`codex.skill.${definition.name}.freshness`, `${definition.name} skill is fresh`, file) : fail(`codex.skill.${definition.name}.freshness`, `${definition.name} skill is stale`, file));
}
return checks;
}
export async function validateRepo(root = process.cwd()): Promise<ValidationCheck[]> {
const checks: ValidationCheck[] = [];
const pkgPath = path.join(root, "package.json");
@@ -387,11 +419,17 @@ export function validateProject(projectRoot: string): ValidationCheck[] {
}
const expectedPromptRoles = new Set(expectedProjectRoles);
for (const role of expectedProjectRoles) checks.push(...stablePromptSectionChecks(projectRoot, role, studio));
for (const role of expectedProjectRoles) {
checks.push(...stablePromptSectionChecks(projectRoot, role, studio));
checks.push(...customAgentChecks(projectRoot, role, studio));
}
checks.push(...skillChecks(projectRoot, studio));
for (const role of studioRoleIds) {
if (!isEngineSpecialistRoleId(role) || expectedPromptRoles.has(role)) continue;
const file = path.join(projectRoot, ".codex", "prompts", `${role}.md`);
checks.push(existsSync(file) ? fail(`codex.role.${role}.prompt.absent`, `${role} prompt must not be materialized for ${studio.engine} projects`, file) : pass(`codex.role.${role}.prompt.absent`, `${role} prompt absent for ${studio.engine} project`, file));
const promptFile = path.join(projectRoot, ".codex", "prompts", `${role}.md`);
checks.push(existsSync(promptFile) ? fail(`codex.role.${role}.prompt.absent`, `${role} prompt must not be materialized for ${studio.engine} projects`, promptFile) : pass(`codex.role.${role}.prompt.absent`, `${role} prompt absent for ${studio.engine} project`, promptFile));
const agentFile = path.join(projectRoot, ".codex", "agents", `${role}.toml`);
checks.push(existsSync(agentFile) ? fail(`codex.agent.${role}.absent`, `${role} custom agent must not be materialized for ${studio.engine} projects`, agentFile) : pass(`codex.agent.${role}.absent`, `${role} custom agent absent for ${studio.engine} project`, agentFile));
}
for (const workflow of workflowIds()) {
@@ -429,10 +467,10 @@ export function validateProject(projectRoot: string): ValidationCheck[] {
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"]) {
for (const file of ["docs/market-overview.md", "design/gdd.md", "production/timeline.md", "docs/architecture/README.md"]) {
checks.push(existsSync(path.join(projectRoot, file)) ? pass(`project.artifact.${file}`, `${file} exists`) : fail(`project.artifact.${file}`, `${file} missing`, path.join(projectRoot, file)));
}
const timeline = path.join(projectRoot, "documentation", "production", "timeline.md");
const timeline = path.join(projectRoot, "production", "timeline.md");
if (existsSync(timeline)) {
const body = readFileSync(timeline, "utf8");
for (const section of ["# Timeline", "# Milestones", "# Risks", "# Next Validation Gate"]) {
@@ -440,7 +478,7 @@ export function validateProject(projectRoot: string): ValidationCheck[] {
}
}
for (const forbidden of ["project_orchestrator.md", "CODEX.md", path.join(".gamestudio", "runs")]) {
for (const forbidden of ["project_orchestrator.md", "CODEX.md", path.join(".gamestudio", "runs"), path.join(".codex", "hooks.json"), path.join(".codex", "agents", "truth-claim-verifier.toml"), path.join(".codex", "agents", "truth-doc-reviewer.toml"), path.join(".codex", "agents", "truth-doc-writer.toml"), path.join(".codex", "agents", "truth-route-auditor.toml")]) {
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`));
}
@@ -455,6 +493,7 @@ export function validateProject(projectRoot: string): ValidationCheck[] {
export async function runValidation(options: { project?: string; root?: string } = {}): Promise<{ checks: ValidationCheck[]; failed: boolean }> {
const root = options.root ?? process.cwd();
const checks = options.project ? validateProject(path.resolve(root, options.project)) : await validateRepo(root);
const projectPath = options.project ? path.resolve(root, options.project) : existsSync(path.join(root, ".codex", "studio.json")) ? root : undefined;
const checks = projectPath ? validateProject(projectPath) : await validateRepo(root);
return { checks, failed: checks.some((check) => check.status === "fail") };
}
+16 -16
View File
@@ -27,7 +27,7 @@ function initCliProject(prefix: string, name: string): { cwd: string; projectRoo
const cwd = mkdtempSync(path.join(tmpdir(), prefix));
tempRoots.push(cwd);
runCli(["init", "--name", name, "--engine", "godot", "--mode", "prototype", "--non-interactive"], cwd);
return { cwd, projectRoot: path.join(cwd, "projects", name.toLowerCase().replace(/\s+/g, "-")) };
return { cwd, projectRoot: cwd };
}
beforeAll(() => {
@@ -42,12 +42,12 @@ describe("built CLI prompt surface", () => {
test("prints inlined project prompt, selected templates, and bounded broad context from temp cwd", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-cli-prompt-"));
tempRoots.push(cwd);
const repoProject = path.join(repoRoot, "projects", "cli-prompt-game");
const repoProject = path.join(repoRoot, ".codex", "studio.json");
const repoProjectExisted = existsSync(repoProject);
try {
execFileSync("node", [cli, "init", "--name", "CLI Prompt Game", "--engine", "godot", "--mode", "design", "--non-interactive"], { cwd, encoding: "utf8" });
const projectRoot = path.join(cwd, "projects", "cli-prompt-game");
const projectRoot = cwd;
const marketPrompt = execFileSync("node", [cli, "run", "market-analyst", "--project", projectRoot, "--print-prompt", "Assess competitors"], {
cwd,
@@ -62,9 +62,9 @@ describe("built CLI prompt surface", () => {
cwd,
encoding: "utf8"
});
expect(dryRun).toContain("- documentation/design/gdd.md");
expect(dryRun).toContain("- documentation/production/timeline.md");
expect(dryRun).toContain("- resources/market-research/market-overview.md");
expect(dryRun).toContain("- design/gdd.md");
expect(dryRun).toContain("- production/timeline.md");
expect(dryRun).toContain("- docs/market-overview.md");
expect((dryRun.match(/- \.codex\/prompts\//g) ?? [])).toHaveLength(1);
expect(existsSync(repoProject)).toBe(repoProjectExisted);
} finally {
@@ -85,7 +85,7 @@ describe("built CLI prompt surface", () => {
title: "Implement jump feel",
role: "gameplay-programmer",
status: "ready",
files: ["documentation/design/gdd.md"],
files: ["design/gdd.md"],
writeFiles: ["source/player.gd"],
dependencies: [],
priority: 0,
@@ -111,7 +111,7 @@ describe("built CLI prompt surface", () => {
"--task",
"task-001",
"--scope",
"source/**/*.gd",
"src/**/*.gd",
"--approved-by",
"lead",
"--expires-at",
@@ -122,7 +122,7 @@ describe("built CLI prompt surface", () => {
expect(grant).toContain("approval-001");
expect(grant).toContain("Implement jump feel");
expect(grant).toContain("source/**/*.gd");
expect(grant).toContain("src/**/*.gd");
const store = JSON.parse(readFileSync(path.join(projectRoot, ".codex", "approvals.json"), "utf8")) as {
records: Array<{ id: string; role: string; approvedFiles?: string[]; source: string }>;
@@ -198,7 +198,7 @@ describe("built CLI prompt surface", () => {
"--task",
hash64,
"--scope",
"source/**/*.gd"
"src/**/*.gd"
],
cwd
)
@@ -215,7 +215,7 @@ describe("built CLI prompt surface", () => {
title: "Plan milestone",
role: "producer",
status: "ready",
files: ["documentation/production/timeline.md"],
files: ["production/timeline.md"],
notes: []
}
]
@@ -236,7 +236,7 @@ describe("built CLI prompt surface", () => {
"--task",
"task-001",
"--scope",
"source/**/*.gd"
"src/**/*.gd"
],
cwd
)
@@ -254,7 +254,7 @@ describe("built CLI prompt surface", () => {
"--task",
"not-a-task",
"--scope",
"source/**/*.gd"
"src/**/*.gd"
],
cwd
)
@@ -274,7 +274,7 @@ describe("built CLI prompt surface", () => {
"--task",
hash64,
"--scope",
"source/**/*.gd",
"src/**/*.gd",
"--expires-at",
"2000-01-01T00:00:00.000Z"
],
@@ -301,7 +301,7 @@ describe("built CLI prompt surface", () => {
"--task",
hash64,
"--scope",
"source/**/*.gd",
"src/**/*.gd",
"--approved-by",
"lead"
],
@@ -313,7 +313,7 @@ describe("built CLI prompt surface", () => {
const store = JSON.parse(readFileSync(path.join(projectRoot, ".codex", "approvals.json"), "utf8")) as {
records: Array<{ role: string; approvedGlobs: string[] }>;
};
expect(store.records[0]).toMatchObject({ role: "custom-boss-designer", approvedGlobs: ["source/**/*.gd"] });
expect(store.records[0]).toMatchObject({ role: "custom-boss-designer", approvedGlobs: ["src/**/*.gd"] });
});
test("run dry-run shows approval override advisory and sandbox provenance", () => {
+22 -21
View File
@@ -32,32 +32,33 @@ describe("Codex context files", () => {
test("path-safe selector records required, missing, unsafe, and budgeted context", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-context-select-"));
const { projectRoot } = initProject({ name: "Selector Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const outside = path.join(cwd, "outside.md");
const outsideDir = mkdtempSync(path.join(tmpdir(), "ogs-outside-"));
const outside = path.join(outsideDir, "outside.md");
writeFileSync(outside, "outside");
symlinkSync(outside, path.join(projectRoot, "documentation", "design", "escape.md"));
writeFileSync(path.join(projectRoot, "documentation", "design", "large.md"), "x".repeat(20_000));
symlinkSync(outside, path.join(projectRoot, "design", "escape.md"));
writeFileSync(path.join(projectRoot, "design", "large.md"), "x".repeat(20_000));
const result = selectContextEntries(projectRoot, [
{ sourcePath: "AGENTS.md", reason: "project instructions", required: true },
{ sourcePath: "documentation/design/missing.md", reason: "missing required", required: true },
{ sourcePath: "documentation/design/gdd.md", reason: "design reference" },
{ sourcePath: "documentation/production/timeline.md", reason: "production reference" },
{ sourcePath: "documentation/design/large.md", reason: "large optional" },
{ sourcePath: "design/missing.md", reason: "missing required", required: true },
{ sourcePath: "design/gdd.md", reason: "design reference" },
{ sourcePath: "production/timeline.md", reason: "production reference" },
{ sourcePath: "design/large.md", reason: "large optional" },
{ sourcePath: "/absolute.md", reason: "absolute", required: true },
{ sourcePath: "../escape.md", reason: "traversal", required: true },
{ sourcePath: ".env", reason: "secret", required: true },
{ sourcePath: "documentation/design/escape.md", reason: "symlink escape", required: true }
{ sourcePath: "design/escape.md", reason: "symlink escape", required: true }
], { maxFiles: 2, maxChars: 50_000, maxEntryChars: 10_000 });
expect(result.selected.map((entry) => entry.sourcePath)).toEqual(["AGENTS.md", "documentation/design/gdd.md"]);
expect(result.selected.map((entry) => entry.sourcePath)).toEqual(["AGENTS.md", "design/gdd.md"]);
expect(result.entries).toContainEqual(expect.objectContaining({ sourcePath: "AGENTS.md", status: "selected", required: true, reason: "project instructions" }));
expect(result.entries).toContainEqual(expect.objectContaining({ sourcePath: "documentation/design/missing.md", status: "missing" }));
expect(result.entries).toContainEqual(expect.objectContaining({ sourcePath: "design/missing.md", status: "missing" }));
expect(result.entries).toContainEqual(expect.objectContaining({ sourcePath: "/absolute.md", status: "rejected", safety: "unsafe", statusReason: expect.stringMatching(/absolute/i) }));
expect(result.entries).toContainEqual(expect.objectContaining({ sourcePath: "../escape.md", status: "rejected", safety: "unsafe", statusReason: expect.stringMatching(/traversal/i) }));
expect(result.entries).toContainEqual(expect.objectContaining({ sourcePath: ".env", status: "rejected", safety: "secret" }));
expect(result.entries).toContainEqual(expect.objectContaining({ sourcePath: "documentation/design/escape.md", status: "rejected", statusReason: expect.stringMatching(/symlink/i) }));
expect(result.entries).toContainEqual(expect.objectContaining({ sourcePath: "documentation/design/large.md", status: "omitted", statusReason: expect.stringMatching(/entry character budget/i) }));
expect(result.entries).toContainEqual(expect.objectContaining({ sourcePath: "documentation/production/timeline.md", status: "omitted", statusReason: expect.stringMatching(/file count budget/i) }));
expect(result.entries).toContainEqual(expect.objectContaining({ sourcePath: "design/escape.md", status: "rejected", statusReason: expect.stringMatching(/symlink/i) }));
expect(result.entries).toContainEqual(expect.objectContaining({ sourcePath: "design/large.md", status: "omitted", statusReason: expect.stringMatching(/entry character budget/i) }));
expect(result.entries).toContainEqual(expect.objectContaining({ sourcePath: "production/timeline.md", status: "omitted", statusReason: expect.stringMatching(/file count budget/i) }));
});
test("selector prioritizes required context over earlier optional context within file budget", () => {
@@ -65,28 +66,28 @@ describe("Codex context files", () => {
const { projectRoot } = initProject({ name: "Required Budget Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const result = selectContextEntries(projectRoot, [
{ sourcePath: "documentation/design/gdd.md", reason: "optional design reference" },
{ sourcePath: "design/gdd.md", reason: "optional design reference" },
{ sourcePath: "AGENTS.md", reason: "project instructions", required: true }
], { maxFiles: 1, maxChars: 10_000, maxEntryChars: 10_000 });
expect(result.selected.map((entry) => entry.sourcePath)).toEqual(["AGENTS.md"]);
expect(result.budget.usedFiles).toBeLessThanOrEqual(1);
expect(result.entries).toContainEqual(expect.objectContaining({ sourcePath: "documentation/design/gdd.md", status: "omitted", statusReason: expect.stringMatching(/file count budget/i) }));
expect(result.entries).toContainEqual(expect.objectContaining({ sourcePath: "design/gdd.md", status: "omitted", statusReason: expect.stringMatching(/file count budget/i) }));
});
test("selector omits oversized required context instead of bypassing character budgets", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-context-required-size-"));
const { projectRoot } = initProject({ name: "Required Size Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
writeFileSync(path.join(projectRoot, "documentation", "design", "huge-required.md"), "x".repeat(20_000));
writeFileSync(path.join(projectRoot, "design", "huge-required.md"), "x".repeat(20_000));
const result = selectContextEntries(projectRoot, [
{ sourcePath: "documentation/design/huge-required.md", reason: "required oversized reference", required: true },
{ sourcePath: "design/huge-required.md", reason: "required oversized reference", required: true },
{ sourcePath: "AGENTS.md", reason: "project instructions", required: true }
], { maxFiles: 2, maxChars: 50_000, maxEntryChars: 10_000 });
expect(result.selected.map((entry) => entry.sourcePath)).toEqual(["AGENTS.md"]);
expect(result.budget.usedChars).toBeLessThanOrEqual(50_000);
expect(result.entries).toContainEqual(expect.objectContaining({ sourcePath: "documentation/design/huge-required.md", status: "omitted", statusReason: expect.stringMatching(/entry character budget/i) }));
expect(result.entries).toContainEqual(expect.objectContaining({ sourcePath: "design/huge-required.md", status: "omitted", statusReason: expect.stringMatching(/entry character budget/i) }));
});
test("selector rejects dotenv variants as secret-like paths", () => {
@@ -118,17 +119,17 @@ describe("Codex context files", () => {
test("broad context selection records missing required files instead of widening reads", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-context-missing-"));
const { projectRoot } = initProject({ name: "Missing Context Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
rmSync(path.join(projectRoot, "documentation", "design", "gdd.md"));
rmSync(path.join(projectRoot, "design", "gdd.md"));
mkdirSync(path.join(projectRoot, "notes"), { recursive: true });
writeFileSync(path.join(projectRoot, "notes", "unrequested.md"), "do not include");
const result = selectContextEntries(projectRoot, [
{ sourcePath: "documentation/design/gdd.md", reason: "required design reference", required: true },
{ sourcePath: "design/gdd.md", reason: "required design reference", required: true },
{ sourcePath: "AGENTS.md", reason: "project instructions", required: true }
]);
expect(result.selected.map((entry) => entry.sourcePath)).toEqual(["AGENTS.md"]);
expect(result.entries).toContainEqual(expect.objectContaining({ sourcePath: "documentation/design/gdd.md", status: "missing" }));
expect(result.entries).toContainEqual(expect.objectContaining({ sourcePath: "design/gdd.md", status: "missing" }));
expect(result.entries.map((entry) => entry.sourcePath)).not.toContain("notes/unrequested.md");
});
+1 -1
View File
@@ -10,7 +10,7 @@ describe("task orchestration", () => {
test("dry-run orchestration plans waves without mutating task state or runs", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-orchestrate-dry-"));
const { projectRoot } = initProject({ name: "Dry Orchestration Game", engine: "godot", mode: "prototype", studioMode: "fast-prototype", nonInteractive: true }, cwd);
const task = createTask(projectRoot, { title: "Implement jump", role: "gameplay-programmer", files: ["documentation/design/gdd.md"], writeFiles: ["source/project-dry-orchestration-game/player.gd"] });
const task = createTask(projectRoot, { title: "Implement jump", role: "gameplay-programmer", files: ["design/gdd.md"], writeFiles: ["source/project-dry-orchestration-game/player.gd"] });
const before = readFileSync(path.join(projectRoot, ".codex", "tasks.json"), "utf8");
const result = await orchestrateTasks({ project: projectRoot, taskIds: [task.id], dryRun: true, maxConcurrency: 1 });
+72 -126
View File
@@ -7,80 +7,80 @@ import { describe, expect, test } from "vitest";
import { guidanceConfigHash } from "../src/config.js";
import { freezeProject, initProject, resumeProject, statusProject } from "../src/projects.js";
function tempRoot(prefix: string): string {
return mkdtempSync(path.join(tmpdir(), prefix));
}
describe("project workflow", () => {
test("init creates project docs, config, agents, and engine files", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-project-"));
test("init configures the current repository root as the game root", () => {
const cwd = tempRoot("ogs-root-project-");
const { projectRoot, config } = initProject({ name: "Test Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
expect(existsSync(path.join(projectRoot, "project-config.json"))).toBe(false);
expect(existsSync(path.join(projectRoot, "CODEX.md"))).toBe(false);
expect(projectRoot).toBe(cwd);
expect(existsSync(path.join(cwd, "projects", "test-game"))).toBe(false);
expect(existsSync(path.join(projectRoot, ".codex", "studio.json"))).toBe(true);
expect(JSON.parse(readFileSync(path.join(projectRoot, ".codex", "approvals.json"), "utf8"))).toEqual({
schemaVersion: 1,
product: "codex-game-studio",
records: []
});
expect(existsSync(path.join(projectRoot, ".codex", "context-manifest.json"))).toBe(true);
expect(existsSync(path.join(projectRoot, ".codex", "context-manifest.meta.json"))).toBe(true);
expect(existsSync(path.join(projectRoot, ".codex", "runs"))).toBe(true);
expect(existsSync(path.join(projectRoot, ".gamestudio"))).toBe(false);
expect(existsSync(path.join(projectRoot, "src", "project.godot"))).toBe(true);
expect(existsSync(path.join(projectRoot, "assets", ".gitkeep"))).toBe(true);
expect(existsSync(path.join(projectRoot, "design", "gdd.md"))).toBe(true);
expect(existsSync(path.join(projectRoot, "docs", "architecture", "README.md"))).toBe(true);
expect(existsSync(path.join(projectRoot, "docs", "market-overview.md"))).toBe(true);
expect(existsSync(path.join(projectRoot, "tests", ".gitkeep"))).toBe(true);
expect(existsSync(path.join(projectRoot, "tools", ".gitkeep"))).toBe(true);
expect(existsSync(path.join(projectRoot, "production", "timeline.md"))).toBe(true);
const contextManifest = JSON.parse(readFileSync(path.join(projectRoot, ".codex", "context-manifest.json"), "utf8"));
const contextManifestMeta = JSON.parse(readFileSync(path.join(projectRoot, ".codex", "context-manifest.meta.json"), "utf8"));
expect(contextManifest).toMatchObject({
schemaVersion: 1,
product: "codex-game-studio",
projectStage: "prototype",
studioMode: "guided-studio"
});
expect(contextManifest.entries).toEqual(
expect.arrayContaining([
expect.objectContaining({ sourcePath: "AGENTS.md", required: true, safety: "safe", status: "selected" }),
expect.objectContaining({ sourcePath: "source/project-test-game/project.godot", reason: expect.stringMatching(/engine/i), status: "selected" })
expect.objectContaining({ sourcePath: "design/gdd.md", required: true, status: "selected" }),
expect.objectContaining({ sourcePath: "src/project.godot", reason: expect.stringMatching(/engine/i), status: "selected" })
])
);
expect(contextManifest.manifestSha256).toBeUndefined();
expect(contextManifestMeta).toMatchObject({
schemaVersion: 1,
product: "codex-game-studio",
projectStage: "prototype",
studioMode: "guided-studio"
});
expect(contextManifestMeta.manifestSha256).toMatch(/^[a-f0-9]{64}$/);
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",
studioMode: "guided-studio",
currentMilestone: "prototype"
});
const agents = readFileSync(path.join(projectRoot, "AGENTS.md"), "utf8");
expect(agents).toContain("# Test Game Agents");
expect(agents).toContain("# Test Game Game Studio");
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"))).toBe(false);
expect(readFileSync(path.join(projectRoot, "AGENTS.md"), "utf8")).toContain(guidanceConfigHash(config));
expect(agents).not.toContain("NodeNext");
expect(agents).not.toContain("Truthmark");
expect(agents).toContain(guidanceConfigHash(config));
expect(config.project.concept).toBe("Test Game concept");
expect(config.project.genre).toBe("Unspecified");
expect(config.project.platform).toBe("PC");
expect(config.project.audience).toBe("General players");
expect(config.project.competitors).toEqual([]);
expect(config.project.monetization).toBe("undecided");
expect(config.project.timeline).toBe("TBD");
expect(existsSync(path.join(projectRoot, "resources", "market-research", "mini-metro.md"))).toBe(false);
});
test("init materializes Codex-native custom agents and repository skills", () => {
const cwd = tempRoot("ogs-surfaces-");
const { projectRoot } = initProject({ name: "Surface Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const agent = readFileSync(path.join(projectRoot, ".codex", "agents", "gameplay-programmer.toml"), "utf8");
expect(agent).toContain('name = "gameplay_programmer"');
expect(agent).toContain("description = ");
expect(agent).toContain("developer_instructions = ");
expect(existsSync(path.join(projectRoot, ".codex", "agents", "gameplay-programmer.md"))).toBe(false);
expect(existsSync(path.join(projectRoot, ".codex", "agents", "godot-specialist.toml"))).toBe(true);
expect(existsSync(path.join(projectRoot, ".codex", "agents", "unity-specialist.toml"))).toBe(false);
expect(existsSync(path.join(projectRoot, ".codex", "agents", "unreal-specialist.toml"))).toBe(false);
expect(existsSync(path.join(projectRoot, ".codex", "hooks.json"))).toBe(false);
expect(existsSync(path.join(projectRoot, ".codex", "rules"))).toBe(false);
for (const skill of ["cgs-start", "cgs-setup-engine", "cgs-adopt", "cgs-bugfix", "cgs-vertical-slice", "cgs-ui-ux-review", "cgs-release-checklist", "cgs-standards-gameplay", "cgs-standards-tests", "cgs-standards-prototype", "cgs-standards-ui"]) {
const body = readFileSync(path.join(projectRoot, ".agents", "skills", skill, "SKILL.md"), "utf8");
expect(body).toContain(`name: ${skill}`);
expect(body).toContain("description: ");
}
});
test("init requires explicit non-interactive mode and supports optional overrides", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-required-"));
const cwd = tempRoot("ogs-required-");
expect(() => initProject({ name: "Missing Mode", engine: "godot", nonInteractive: true }, cwd)).toThrow(/--mode/);
expect(() => initProject({ name: "Missing Noninteractive", engine: "godot", mode: "prototype" }, cwd)).toThrow(/--non-interactive/);
const { config } = initProject({
@@ -97,101 +97,47 @@ describe("project workflow", () => {
expect(config.project.engine_version).toBe("4.5.custom");
});
test("init generates an empty approval store", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-approval-project-"));
const { projectRoot } = initProject({ name: "Approval Project", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
expect(JSON.parse(readFileSync(path.join(projectRoot, ".codex", "approvals.json"), "utf8"))).toEqual({
schemaVersion: 1,
product: "codex-game-studio",
records: []
});
test("default init protects an existing different root project", () => {
const cwd = tempRoot("ogs-protect-");
initProject({ name: "First Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
expect(() => initProject({ name: "Second Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd)).toThrow(/already contains/i);
});
test("context manifest files describe selected context and sidecar freshness", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-context-manifest-"));
const { projectRoot } = initProject({ name: "Manifest Game", engine: "godot", mode: "development", studioMode: "strict-studio", nonInteractive: true }, cwd);
const manifest = JSON.parse(readFileSync(path.join(projectRoot, ".codex", "context-manifest.json"), "utf8"));
const meta = JSON.parse(readFileSync(path.join(projectRoot, ".codex", "context-manifest.meta.json"), "utf8"));
expect(manifest).toMatchObject({ schemaVersion: 1, projectStage: "development", studioMode: "strict-studio" });
expect(manifest.entries).toEqual(
expect.arrayContaining([
expect.objectContaining({ sourcePath: "AGENTS.md", required: true, status: "selected" }),
expect.objectContaining({ sourcePath: "source/project-manifest-game/project.godot", reason: "godot engine reference" })
])
);
expect(meta).toMatchObject({ schemaVersion: 1, projectStage: "development", studioMode: "strict-studio" });
expect(meta.manifestSha256).toMatch(/^[a-f0-9]{64}$/);
expect(meta.inputsSha256).toMatch(/^[a-f0-9]{64}$/);
test("explicit nested mode preserves legacy projects slug layout", () => {
const cwd = tempRoot("ogs-nested-");
const { projectRoot } = initProject({ name: "Nested Game", engine: "godot", mode: "prototype", nonInteractive: true, nested: true }, cwd);
expect(projectRoot).toBe(path.join(cwd, "projects", "nested-game"));
expect(existsSync(path.join(projectRoot, ".codex", "studio.json"))).toBe(true);
});
test("CLI init requires mode and non-interactive and accepts repeated competitor flags", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-cli-"));
test("CLI init writes root studio state by default and accepts repeated competitor flags", () => {
const cwd = tempRoot("ogs-cli-");
const cli = path.join(process.cwd(), "src", "cli.ts");
const tsx = path.join(process.cwd(), "node_modules", ".bin", "tsx");
expect(() => execFileSync(tsx, [cli, "init", "--name", "CLI Missing Mode", "--engine", "godot", "--non-interactive"], { cwd, encoding: "utf8", stdio: "pipe" })).toThrow();
execFileSync(tsx, [
cli,
"init",
"--name",
"CLI Game",
"--engine",
"godot",
"--mode",
"prototype",
"--studio-mode",
"fast-prototype",
"--non-interactive",
"--competitor",
"terra nil",
"--competitor",
"mini metro",
"--engine-version",
"4.5.custom"
], { cwd, encoding: "utf8" });
const studio = JSON.parse(readFileSync(path.join(cwd, "projects", "cli-game", ".codex", "studio.json"), "utf8"));
execFileSync(tsx, [cli, "init", "--name", "CLI Game", "--engine", "godot", "--mode", "prototype", "--studio-mode", "fast-prototype", "--non-interactive", "--competitor", "terra nil", "--competitor", "mini metro", "--engine-version", "4.5.custom"], { cwd, encoding: "utf8" });
const studio = JSON.parse(readFileSync(path.join(cwd, ".codex", "studio.json"), "utf8"));
expect(studio.engineVersion).toBe("4.5.custom");
expect(studio.studioMode).toBe("fast-prototype");
expect(existsSync(path.join(cwd, "projects", "cli-game"))).toBe(false);
});
test("CLI init does not expose arbitrary project root override", () => {
const cli = path.join(process.cwd(), "src", "cli.ts");
const tsx = path.join(process.cwd(), "node_modules", ".bin", "tsx");
const help = execFileSync(tsx, [cli, "init", "--help"], { encoding: "utf8" });
expect(help).not.toContain("--root");
});
test("init ignores arbitrary root override and stays under projects slug", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-root-"));
const outsideRoot = path.join(cwd, "outside-root");
const { projectRoot } = initProject({ name: "Root Escape", engine: "godot", mode: "prototype", nonInteractive: true, root: outsideRoot } as Parameters<typeof initProject>[0], cwd);
expect(projectRoot).toBe(path.join(cwd, "projects", "root-escape"));
expect(existsSync(outsideRoot)).toBe(false);
});
test("all engines initialize expected files", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-engines-"));
expect(existsSync(path.join(initProject({ name: "Godot Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd).projectRoot, "source", "project-godot-game", "project.godot"))).toBe(true);
expect(existsSync(path.join(initProject({ name: "Unity Game", engine: "unity", mode: "design", nonInteractive: true }, cwd).projectRoot, "source", "project-unity-game", "Packages", "manifest.json"))).toBe(true);
expect(existsSync(path.join(initProject({ name: "Unreal Game", engine: "Unreal Engine", mode: "development", nonInteractive: true }, cwd).projectRoot, "source", "project-unreal-game", "UnrealGame.uproject"))).toBe(true);
});
test("same-parent init rejects Unreal class-name collisions", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-collision-"));
initProject({ name: "Foo1", engine: "unreal", mode: "prototype", nonInteractive: true }, cwd);
expect(() => initProject({ name: "Foo 1", engine: "unreal", mode: "prototype", nonInteractive: true }, cwd)).toThrow(/collides/i);
test("all engines initialize expected root files", () => {
expect(existsSync(path.join(initProject({ name: "Godot Game", engine: "godot", mode: "prototype", nonInteractive: true }, tempRoot("ogs-godot-")).projectRoot, "src", "project.godot"))).toBe(true);
expect(existsSync(path.join(initProject({ name: "Unity Game", engine: "unity", mode: "design", nonInteractive: true }, tempRoot("ogs-unity-")).projectRoot, "src", "Packages", "manifest.json"))).toBe(true);
expect(existsSync(path.join(initProject({ name: "Unreal Game", engine: "Unreal Engine", mode: "development", nonInteractive: true }, tempRoot("ogs-unreal-")).projectRoot, "src", "UnrealGame.uproject"))).toBe(true);
});
test("status resume are read-only and freeze only changes operational status", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-status-"));
const cwd = tempRoot("ogs-status-");
const { projectRoot } = initProject({ name: "Freeze Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
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(statusProject(undefined, cwd)).toContain("custom agents: .codex/agents/*.toml");
expect(resumeProject(undefined, cwd)).toContain("Suggested next command");
expect(readFileSync(studioPath, "utf8")).toBe(before);
const agentsBefore = readFileSync(path.join(projectRoot, "AGENTS.md"), "utf8");
freezeProject(projectRoot, cwd);
freezeProject(undefined, cwd);
expect(JSON.parse(readFileSync(studioPath, "utf8")).status).toBe("frozen");
expect(readFileSync(path.join(projectRoot, "AGENTS.md"), "utf8")).toBe(agentsBefore);
});
+21 -20
View File
@@ -52,7 +52,7 @@ describe("runner", () => {
metadata: { provenance: "override", approvedByUser: true }
});
const fast = initProject({ name: "Fast Game", engine: "godot", mode: "prototype", studioMode: "fast-prototype", nonInteractive: true }, cwd);
const fast = initProject({ name: "Fast Game", engine: "godot", mode: "prototype", studioMode: "fast-prototype", nonInteractive: true }, mkdtempSync(path.join(tmpdir(), "ogs-runner-policy-")));
const fastRun = prepareRun("gameplay-programmer", { project: fast.projectRoot, task: "Implement movement", codexBin: path.join(cwd, "missing-codex") }, cwd);
const fastMetadata = JSON.parse(readFileSync(fastRun.metadataPath, "utf8")) as { eligibility: { writePolicy: string; metadata: { provenance: string } } };
expect(fastRun.output).toContain("Write policy: advisory-write");
@@ -150,13 +150,13 @@ describe("runner", () => {
const { projectRoot } = initProject({ name: "Broad Context Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const narrow = prepareRun("producer", { project: projectRoot, task: "Plan next milestone", dryRun: true }, cwd);
expect(narrow.contextFiles).not.toContain("documentation/design/gdd.md");
expect(narrow.contextFiles).not.toContain("documentation/production/timeline.md");
expect(narrow.contextFiles).not.toContain("design/gdd.md");
expect(narrow.contextFiles).not.toContain("production/timeline.md");
const broad = prepareRun("producer", { project: projectRoot, task: "Plan next milestone", dryRun: true, allowBroadContext: true }, cwd);
expect(broad.contextFiles).toContain("documentation/design/gdd.md");
expect(broad.contextFiles).toContain("documentation/production/timeline.md");
expect(broad.contextFiles).toContain("resources/market-research/market-overview.md");
expect(broad.contextFiles).toContain("design/gdd.md");
expect(broad.contextFiles).toContain("production/timeline.md");
expect(broad.contextFiles).toContain("docs/market-overview.md");
expect(broad.contextFiles).not.toContain("Broad context explicitly allowed by CLI flag.");
expect(broad.contextFiles.filter((file) => file.startsWith(".codex/prompts/"))).toHaveLength(1);
});
@@ -164,30 +164,31 @@ describe("runner", () => {
test("broad context ignores directories and realpath escapes", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-runner-"));
const { projectRoot } = initProject({ name: "Bounded Context Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const gdd = path.join(projectRoot, "documentation", "design", "gdd.md");
const gdd = path.join(projectRoot, "design", "gdd.md");
rmSync(gdd);
mkdirSync(gdd);
const overview = path.join(projectRoot, "resources", "market-research", "market-overview.md");
const outside = path.join(cwd, "outside.md");
const overview = path.join(projectRoot, "docs", "market-overview.md");
const outsideDir = mkdtempSync(path.join(tmpdir(), "ogs-outside-"));
const outside = path.join(outsideDir, "outside.md");
writeFileSync(outside, "outside");
rmSync(overview);
symlinkSync(outside, overview);
const broad = prepareRun("producer", { project: projectRoot, task: "Plan next milestone", dryRun: true, allowBroadContext: true }, cwd);
expect(broad.contextFiles).not.toContain("documentation/design/gdd.md");
expect(broad.contextFiles).not.toContain("resources/market-research/market-overview.md");
expect(broad.contextFiles).not.toContain("design/gdd.md");
expect(broad.contextFiles).not.toContain("docs/market-overview.md");
});
test("include artifact renders canonical project-relative paths", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-runner-"));
const { projectRoot } = initProject({ name: "Artifact Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const run = prepareRun("producer", { project: projectRoot, task: "Plan next milestone", printPrompt: true, includeArtifact: ["documentation/design/../design/gdd.md"] }, cwd);
const run = prepareRun("producer", { project: projectRoot, task: "Plan next milestone", printPrompt: true, includeArtifact: ["design/../design/gdd.md"] }, cwd);
expect(run.contextFiles).toContain("documentation/design/gdd.md");
expect(run.prompt).toContain("# Included Artifact: documentation/design/gdd.md");
expect(() => prepareRun("producer", { project: projectRoot, task: "Plan next milestone", printPrompt: true, includeArtifact: ["documentation/design/gdd.md\n# injected"] }, cwd)).toThrow(
expect(run.contextFiles).toContain("design/gdd.md");
expect(run.prompt).toContain("# Included Artifact: design/gdd.md");
expect(() => prepareRun("producer", { project: projectRoot, task: "Plan next milestone", printPrompt: true, includeArtifact: ["design/gdd.md\n# injected"] }, cwd)).toThrow(
"--include-artifact cannot contain control characters"
);
});
@@ -209,14 +210,14 @@ describe("runner", () => {
test("oversized included artifacts are not embedded in prompts", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-runner-artifact-oversized-"));
const { projectRoot } = initProject({ name: "Oversized Artifact Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const artifactPath = path.join(projectRoot, "documentation", "design", "large-artifact.md");
const artifactPath = path.join(projectRoot, "design", "large-artifact.md");
writeFileSync(artifactPath, `OVERSIZED_ARTIFACT_BODY\n${"x".repeat(20_000)}`);
const run = prepareRun("producer", { project: projectRoot, task: "Plan next milestone", printPrompt: true, includeArtifact: ["documentation/design/large-artifact.md"] }, cwd);
const run = prepareRun("producer", { project: projectRoot, task: "Plan next milestone", printPrompt: true, includeArtifact: ["design/large-artifact.md"] }, cwd);
expect(run.contextFiles).not.toContain("documentation/design/large-artifact.md");
expect(run.prompt).toContain("- documentation/design/large-artifact.md: omitted");
expect(run.prompt).not.toContain("# Included Artifact: documentation/design/large-artifact.md");
expect(run.contextFiles).not.toContain("design/large-artifact.md");
expect(run.prompt).toContain("- design/large-artifact.md: omitted");
expect(run.prompt).not.toContain("# Included Artifact: design/large-artifact.md");
expect(run.prompt).not.toContain("OVERSIZED_ARTIFACT_BODY");
});
+1 -1
View File
@@ -16,7 +16,7 @@ describe("tasks", () => {
test("strict studio blocks unapproved task runs before run metadata writes or task mutation", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-task-strict-"));
const { projectRoot } = initProject({ name: "Strict Task Game", engine: "godot", mode: "prototype", studioMode: "strict-studio", nonInteractive: true }, cwd);
const task = createTask(projectRoot, { title: "Implement jump", role: "gameplay-programmer", files: ["documentation/design/gdd.md"] });
const task = createTask(projectRoot, { title: "Implement jump", role: "gameplay-programmer", files: ["design/gdd.md"] });
const beforeStore = readFileSync(path.join(projectRoot, ".codex", "tasks.json"), "utf8");
const beforeRuns = readdirSync(path.join(projectRoot, ".codex", "runs"));
+55
View File
@@ -0,0 +1,55 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { checkCodexAvailability } from "../src/codex-runtime.js";
const repoRoot = process.cwd();
const cli = path.join(repoRoot, "dist", "cli.js");
function listFiles(root: string, dir: string): string[] {
const full = path.join(root, dir);
if (!existsSync(full)) return [];
return readdirSync(full, { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => path.join(entry.parentPath, entry.name).slice(root.length + 1).split(path.sep).join("/"))
.sort();
}
describe("template root smoke", () => {
test("clone-style root init creates game-facing Codex surfaces without maintainer source", () => {
execFileSync("npm", ["run", "build", "--silent"], { cwd: repoRoot, encoding: "utf8" });
const root = mkdtempSync(path.join(tmpdir(), "ogs-template-root-"));
try {
execFileSync("node", [cli, "init", "--name", "Clone Root Game", "--engine", "godot", "--mode", "prototype", "--non-interactive"], { cwd: root, encoding: "utf8" });
execFileSync("node", [cli, "validate"], { cwd: root, encoding: "utf8" });
expect(existsSync(path.join(root, "AGENTS.md"))).toBe(true);
expect(existsSync(path.join(root, ".codex", "studio.json"))).toBe(true);
expect(existsSync(path.join(root, ".codex", "agents", "producer.toml"))).toBe(true);
expect(existsSync(path.join(root, ".agents", "skills", "cgs-start", "SKILL.md"))).toBe(true);
expect(existsSync(path.join(root, "src", "project.godot"))).toBe(true);
expect(existsSync(path.join(root, "docs", "architecture", "README.md"))).toBe(true);
expect(existsSync(path.join(root, "projects", "clone-root-game"))).toBe(false);
expect(existsSync(path.join(root, ".codex", "hooks.json"))).toBe(false);
expect(existsSync(path.join(root, ".codex", "rules"))).toBe(false);
expect(listFiles(root, ".codex/agents").filter((file) => file.endsWith(".md"))).toEqual([]);
expect(listFiles(root, "src").filter((file) => file.endsWith(".ts"))).toEqual([]);
expect(existsSync(path.join(root, "docs", "plans"))).toBe(false);
expect(existsSync(path.join(root, "docs", "truthmark"))).toBe(false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("optional Codex probe reports availability without failing offline environments", async () => {
const availability = await checkCodexAvailability();
if (!availability.ok) {
expect(availability.reason).toMatch(/Codex CLI|not found|unavailable|exited/i);
return;
}
expect(availability.command).toBeTruthy();
});
});
+14 -14
View File
@@ -19,7 +19,7 @@ const validApprovalRecord = {
stage: "approved",
role: "gameplay-programmer",
objectiveSha256: "0".repeat(64),
approvedGlobs: ["source/**/*.ts"],
approvedGlobs: ["src/**/*.ts"],
source: "draft-workflow",
approvedBy: "designer",
approvedAt: "2026-06-13T00:00:00.000Z"
@@ -41,12 +41,12 @@ describe("validation", () => {
});
test("fresh initialized projects validate and failures are explicit", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-"));
for (const [name, engine, mode] of [
["Godot Val", "godot", "prototype"],
["Unity Val", "unity", "design"],
["Unreal Val", "ue5", "development"]
] as const) {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-"));
const { projectRoot } = initProject({ name, engine, mode, nonInteractive: true }, cwd);
expect(validateProject(projectRoot).filter((c) => c.status === "fail")).toEqual([]);
}
@@ -56,7 +56,7 @@ describe("validation", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-"));
const { projectRoot } = initProject({ name: "Broken Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
rmSync(path.join(projectRoot, "AGENTS.md"));
rmSync(path.join(projectRoot, "source", "project-broken-game", "project.godot"));
rmSync(path.join(projectRoot, "src", "project.godot"));
const failures = validateProject(projectRoot).filter((c) => c.status === "fail");
expect(failures.map((f) => f.id)).toContain("project.agents_md");
expect(failures.map((f) => f.id)).toContain("project.engine_file");
@@ -76,7 +76,7 @@ describe("validation", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-"));
const { projectRoot } = initProject({ name: "Timeline Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
writeFileSync(
path.join(projectRoot, "documentation", "production", "timeline.md"),
path.join(projectRoot, "production", "timeline.md"),
"# Timeline\n\nTBD\n\n# Milestones\n\n# Risks\n\n- Scope risk.\n\n# Next Validation Gate\n\nRun validation.\n"
);
const failures = validateProject(projectRoot).filter((c) => c.status === "fail");
@@ -86,7 +86,7 @@ describe("validation", () => {
test("Unity validation fails when ProjectSettings marker is missing", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-"));
const { projectRoot } = initProject({ name: "Broken Unity", engine: "unity", mode: "design", nonInteractive: true }, cwd);
rmSync(path.join(projectRoot, "source", "project-broken-unity", "ProjectSettings", "ProjectSettings.asset"));
rmSync(path.join(projectRoot, "src", "ProjectSettings", "ProjectSettings.asset"));
const failures = validateProject(projectRoot).filter((c) => c.status === "fail");
expect(failures.map((f) => f.id)).toContain("project.engine_settings");
});
@@ -139,7 +139,7 @@ describe("validation", () => {
expect.objectContaining({ id: "codex.project.context_manifest", message: expect.stringMatching(/invalid JSON/i) })
);
const { projectRoot: staleProject } = initProject({ name: "Stale Manifest Val", engine: "godot", mode: "prototype", studioMode: "strict-studio", nonInteractive: true }, cwd);
const { projectRoot: staleProject } = initProject({ name: "Stale Manifest Val", engine: "godot", mode: "prototype", studioMode: "strict-studio", nonInteractive: true }, mkdtempSync(path.join(tmpdir(), "ogs-val-")));
const metaPath = path.join(staleProject, ".codex", "context-manifest.meta.json");
const meta = JSON.parse(readFileSync(metaPath, "utf8"));
meta.studioMode = "guided-studio";
@@ -238,34 +238,34 @@ describe("validation", () => {
writeFileSync(rolePrompt, readFileSync(rolePrompt, "utf8").replace(/source-input-sha256: [a-f0-9]+/, "source-input-sha256: bad"));
expect(validateProject(projectRoot).filter((c) => c.status === "fail").map((c) => c.id)).toContain("codex.role.market-analyst.prompt.freshness");
const { projectRoot: bodyProject } = initProject({ name: "Body Freshness Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const { projectRoot: bodyProject } = initProject({ name: "Body Freshness Game", engine: "godot", mode: "prototype", nonInteractive: true }, mkdtempSync(path.join(tmpdir(), "ogs-val-")));
const bodyPrompt = path.join(bodyProject, ".codex", "prompts", "market-analyst.md");
writeFileSync(bodyPrompt, `${readFileSync(bodyPrompt, "utf8")}\nTampered body.\n`);
expect(validateProject(bodyProject).filter((c) => c.status === "fail").map((c) => c.id)).toContain("codex.role.market-analyst.prompt.body");
const { projectRoot: metadataBodyProject } = initProject({ name: "Metadata Body Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const { projectRoot: metadataBodyProject } = initProject({ name: "Metadata Body Game", engine: "godot", mode: "prototype", nonInteractive: true }, mkdtempSync(path.join(tmpdir(), "ogs-val-")));
const metadataBodyPrompt = path.join(metadataBodyProject, ".codex", "prompts", "market-analyst.md");
writeFileSync(metadataBodyPrompt, `${readFileSync(metadataBodyPrompt, "utf8")}\n<!-- source-input-sha256: deadbeef -->\n`);
expect(validateProject(metadataBodyProject).filter((c) => c.status === "fail").map((c) => c.id)).toContain("codex.role.market-analyst.prompt.body");
const { projectRoot: rendererProject } = initProject({ name: "Renderer Drift Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const { projectRoot: rendererProject } = initProject({ name: "Renderer Drift Game", engine: "godot", mode: "prototype", nonInteractive: true }, mkdtempSync(path.join(tmpdir(), "ogs-val-")));
const rendererPrompt = path.join(rendererProject, ".codex", "prompts", "market-analyst.md");
const changedBody = readFileSync(rendererPrompt, "utf8").replace("# Market Analyst", "# Market Research Analyst");
const changedHash = hashGeneratedBody(stripGeneratedMetadata(changedBody));
writeFileSync(rendererPrompt, changedBody.replace(/rendered-body-sha256: [a-f0-9]+/, `rendered-body-sha256: ${changedHash}`));
expect(validateProject(rendererProject).filter((c) => c.status === "fail").map((c) => c.id)).toContain("codex.role.market-analyst.prompt.body");
const { projectRoot: workflowProject } = initProject({ name: "Workflow Freshness Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const { projectRoot: workflowProject } = initProject({ name: "Workflow Freshness Game", engine: "godot", mode: "prototype", nonInteractive: true }, mkdtempSync(path.join(tmpdir(), "ogs-val-")));
const workflow = path.join(workflowProject, ".codex", "workflows", "ui-ux-review.md");
writeFileSync(workflow, readFileSync(workflow, "utf8").replace(/source-input-sha256: [a-f0-9]+/, "source-input-sha256: bad"));
expect(validateProject(workflowProject).filter((c) => c.status === "fail").map((c) => c.id)).toContain("codex.workflow.ui-ux-review.freshness");
const { projectRoot: workflowBodyProject } = initProject({ name: "Workflow Body Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const { projectRoot: workflowBodyProject } = initProject({ name: "Workflow Body Game", engine: "godot", mode: "prototype", nonInteractive: true }, mkdtempSync(path.join(tmpdir(), "ogs-val-")));
const workflowBody = path.join(workflowBodyProject, ".codex", "workflows", "ui-ux-review.md");
writeFileSync(workflowBody, `${readFileSync(workflowBody, "utf8")}\nTampered workflow body.\n`);
expect(validateProject(workflowBodyProject).filter((c) => c.status === "fail").map((c) => c.id)).toContain("codex.workflow.ui-ux-review.body");
const { projectRoot: legacyProject } = initProject({ name: "Legacy Surface Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const { projectRoot: legacyProject } = initProject({ name: "Legacy Surface Game", engine: "godot", mode: "prototype", nonInteractive: true }, mkdtempSync(path.join(tmpdir(), "ogs-val-")));
const legacyPrompt = path.join(legacyProject, ".codex", "prompts", "market-analyst.md");
writeFileSync(legacyPrompt, readFileSync(legacyPrompt, "utf8").replace(/^<!-- .* -->\n/gm, ""));
const legacyChecks = validateProject(legacyProject);
@@ -282,13 +282,13 @@ describe("validation", () => {
const malformedFailures = validateProject(malformedProject).filter((c) => c.status === "fail").map((c) => c.id);
expect(malformedFailures).toContain("codex.role.market-analyst.prompt.freshness");
const { projectRoot: partialProject } = initProject({ name: "Partial Surface Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const { projectRoot: partialProject } = initProject({ name: "Partial Surface Game", engine: "godot", mode: "prototype", nonInteractive: true }, mkdtempSync(path.join(tmpdir(), "ogs-val-")));
const partialWorkflow = path.join(partialProject, ".codex", "workflows", "ui-ux-review.md");
writeFileSync(partialWorkflow, readFileSync(partialWorkflow, "utf8").replace(/^<!-- rendered-body-sha256: .* -->\n/m, ""));
const partialFailures = validateProject(partialProject).filter((c) => c.status === "fail").map((c) => c.id);
expect(partialFailures).toContain("codex.workflow.ui-ux-review.body");
const { projectRoot: legacyProject } = initProject({ name: "Commentless Surface Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
const { projectRoot: legacyProject } = initProject({ name: "Commentless Surface Game", engine: "godot", mode: "prototype", nonInteractive: true }, mkdtempSync(path.join(tmpdir(), "ogs-val-")));
const legacyPrompt = path.join(legacyProject, ".codex", "prompts", "market-analyst.md");
writeFileSync(legacyPrompt, readFileSync(legacyPrompt, "utf8").replace(/^<!-- .* -->\n/gm, ""));
expect(validateProject(legacyProject)).toContainEqual(expect.objectContaining({ id: "codex.role.market-analyst.prompt.freshness", status: "skip" }));