diff --git a/README.md b/README.md index e92e8b4..b3fd740 100644 --- a/README.md +++ b/README.md @@ -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 `. ```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//`. | +| Local project scaffolding | Initializes the current repository root as the game workspace; `--nested` keeps the legacy `projects//` 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. | diff --git a/docs/truthmark/engineering/projects/project-scaffolding.md b/docs/truthmark/engineering/projects/project-scaffolding.md index a1490a0..831a0da 100644 --- a/docs/truthmark/engineering/projects/project-scaffolding.md +++ b/docs/truthmark/engineering/projects/project-scaffolding.md @@ -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//`. -- 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//` 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//` 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//`. +- `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 --engine --mode --non-interactive [--studio-mode ]` creates a project and prints the created path. +- `codex-game-studio init --name --engine --mode --non-interactive [--studio-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 ` reads `.codex/studio.json`. -- `codex-game-studio resume --project ` reads `.codex/studio.json`. -- `codex-game-studio freeze --project ` operates on `.codex/studio.json` and changes status. +- `codex-game-studio status [--project ]` reads `.codex/studio.json`. +- `codex-game-studio resume [--project ]` reads `.codex/studio.json`. +- `codex-game-studio freeze [--project ]` 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 diff --git a/docs/truthmark/engineering/repository/overview.md b/docs/truthmark/engineering/repository/overview.md index 5aa5b9d..88a9012 100644 --- a/docs/truthmark/engineering/repository/overview.md +++ b/docs/truthmark/engineering/repository/overview.md @@ -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/`. +- Initialization commands create deterministic game project structure in the current repository root by default; `--nested` preserves the legacy `projects/` 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. diff --git a/docs/truthmark/routes/areas/repository.md b/docs/truthmark/routes/areas/repository.md index 780c974..0131707 100644 --- a/docs/truthmark/routes/areas/repository.md +++ b/docs/truthmark/routes/areas/repository.md @@ -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 diff --git a/docs/user-guide.md b/docs/user-guide.md index 7bf41e4..f48b685 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -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//` 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//`. 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 ` 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//`. | +| `init` / `new` | Initialize the current repository root as the game workspace; pass `--nested` only for the legacy `projects//` 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. | diff --git a/openspec/changes/repository-root-codex-game-studio/tasks.md b/openspec/changes/repository-root-codex-game-studio/tasks.md index 7bda960..d2b529b 100644 --- a/openspec/changes/repository-root-codex-game-studio/tasks.md +++ b/openspec/changes/repository-root-codex-game-studio/tasks.md @@ -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//`. -- [ ] 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//`. +- [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//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//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//`, 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//`, 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 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 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`. diff --git a/src/agents.ts b/src/agents.ts index 2f38d6f..605f1cc 100644 --- a/src/agents.ts +++ b/src/agents.ts @@ -57,7 +57,7 @@ export function generateProjectAgentsMd(config: ProjectConfig): string { const hash = guidanceConfigHash(config); return ` -# ${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; } diff --git a/src/cli.ts b/src/cli.ts index 2673717..0d52bd5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -88,6 +88,8 @@ function addInitCommand(name: "init" | "new"): void { .option("--monetization ", "monetization model") .option("--timeline ", "timeline") .option("--engine-version ", "engine version override") + .option("--nested", "legacy: create projects/ 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 ", "project path") + .option("--project ", "project path") .action((opts) => console.log(resumeProject(opts.project))); program .command("refresh-context") .description("Regenerate the project context manifest and freshness metadata") - .requiredOption("--project ", "project path") + .option("--project ", "project path") .action((opts) => console.log(refreshContextManifestProject(opts.project))); program .command("freeze") .description("Set project status to frozen") - .requiredOption("--project ", "project path") + .option("--project ", "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 ", "project path") + .option("--project ", "project path") .requiredOption("--role ", "studio role") .requiredOption("--task ", "task id or precomputed objective sha256") .option("--scope ", "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 ", "project path") + .option("--project ", "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 ", "project path") + .option("--project ", "project path") .requiredOption("--approval-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("") .argument("[objective...]") - .requiredOption("--project ", "project path") + .option("--project ", "project path") .option("--task ", "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 ", "project path") + .option("--project ", "project path") .requiredOption("--role ", "studio role") .option("--file ", "read/context file for the task; repeat for multiple files", (value, previous: string[] = []) => [...previous, value], []) .option("--write-file ", "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 ", "project path") + .option("--project ", "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 ", "project path") + .option("--project ", "project path") .option("--workflow ", "select ready tasks from one workflow") .option("--max-concurrency ", "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 ", { isDefault: true }) .description("Render a built-in or project-local workflow prompt by id") - .requiredOption("--project ", "project path") + .option("--project ", "project path") .option("--dry-run", "render prompt without launching Codex") .action((workflowId: string, opts) => renderWorkflowCommand(workflowId, opts)); const workflowTasks = workflow.command("create-tasks ").description("Create explicit file-backed tasks from a workflow recipe"); workflowTasks - .requiredOption("--project ", "project path") + .option("--project ", "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 ", "project path") + .option("--project ", "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 ", "project path") + .option("--project ", "project path") .option("--dry-run", "render prompt without launching Codex") .action((opts) => renderWorkflowCommand(workflow.id, opts)); } diff --git a/src/context-manifest.ts b/src/context-manifest.ts index 26096b4..57fb868 100644 --- a/src/context-manifest.ts +++ b/src/context-manifest.ts @@ -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) ]; diff --git a/src/engines.ts b/src/engines.ts index ade8560..58777d2 100644 --- a/src/engines.ts +++ b/src/engines.ts @@ -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 { diff --git a/src/projects.ts b/src/projects.ts index 50b5bcf..a8ec6a0 100644 --- a/src/projects.ts +++ b/src/projects.ts @@ -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"); } diff --git a/src/runner.ts b/src/runner.ts index 22c649e..5de6a74 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -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 }; } diff --git a/src/skills.ts b/src/skills.ts new file mode 100644 index 0000000..1b5924f --- /dev/null +++ b/src/skills.ts @@ -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; +} diff --git a/src/validation.ts b/src/validation.ts index febe66d..c2287ce 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -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 { 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") }; } diff --git a/tests/cli-prompt-surface.test.ts b/tests/cli-prompt-surface.test.ts index c4adca5..e22b593 100644 --- a/tests/cli-prompt-surface.test.ts +++ b/tests/cli-prompt-surface.test.ts @@ -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", () => { diff --git a/tests/codex-context-files.test.ts b/tests/codex-context-files.test.ts index ac97cf5..0b66266 100644 --- a/tests/codex-context-files.test.ts +++ b/tests/codex-context-files.test.ts @@ -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"); }); diff --git a/tests/orchestrator.test.ts b/tests/orchestrator.test.ts index b605059..2346925 100644 --- a/tests/orchestrator.test.ts +++ b/tests/orchestrator.test.ts @@ -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 }); diff --git a/tests/project-workflow.test.ts b/tests/project-workflow.test.ts index de5f7f3..56f25b0 100644 --- a/tests/project-workflow.test.ts +++ b/tests/project-workflow.test.ts @@ -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[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); }); diff --git a/tests/runner.test.ts b/tests/runner.test.ts index cdbe234..6dbf76d 100644 --- a/tests/runner.test.ts +++ b/tests/runner.test.ts @@ -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"); }); diff --git a/tests/tasks.test.ts b/tests/tasks.test.ts index 40fe79e..6bb577f 100644 --- a/tests/tasks.test.ts +++ b/tests/tasks.test.ts @@ -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")); diff --git a/tests/template-root-smoke.test.ts b/tests/template-root-smoke.test.ts new file mode 100644 index 0000000..57557d8 --- /dev/null +++ b/tests/template-root-smoke.test.ts @@ -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(); + }); +}); diff --git a/tests/validation.test.ts b/tests/validation.test.ts index 2e02627..5877290 100644 --- a/tests/validation.test.ts +++ b/tests/validation.test.ts @@ -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\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(/^\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" }));