mirror of
https://github.com/merlinhu1/codex-game-studio.git
synced 2026-08-25 07:54:34 +02:00
feat: implement repository-root codex surfaces
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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") };
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"));
|
||||
|
||||
|
||||
@@ -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
@@ -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" }));
|
||||
|
||||
Reference in New Issue
Block a user