mirror of
https://github.com/merlinhu1/codex-game-studio.git
synced 2026-08-25 07:54:34 +02:00
feat: expand Codex prompt surfaces
Inline generated project prompts and selected templates into Codex runs, add bounded broad-context discovery, and validate generated surface freshness against current renderer output. Add richer workflow templates, CLI prompt-surface coverage, and synced truth docs.
This commit is contained in:
@@ -90,7 +90,9 @@ Inspect the generated prompt packet without executing Codex:
|
||||
node dist/cli.js run producer --project projects/my-game "Create the initial market overview." --dry-run
|
||||
```
|
||||
|
||||
The `run` command writes a bounded prompt packet and immediately invokes `codex exec` in the project root. Use `--dry-run` or `--print-prompt` when you want to inspect the exact Codex context first.
|
||||
The `run` command writes a bounded prompt packet and immediately invokes `codex exec` in the project root. The packet inlines the generated project role prompt from `.codex/prompts/<role>.md` and only the package templates selected for that role and task. `--fix` uses the same generated role prompt and selected templates as the primary implementation prompt. Use `--dry-run` or `--print-prompt` when you want to inspect the exact Codex context first.
|
||||
|
||||
`--allow-broad-context` adds bounded project artifact discovery for existing files such as the GDD, production timeline, market overview, `AGENTS.md`, and `.codex/studio.json`; it does not recursively load every prompt, workflow, or template.
|
||||
|
||||
## CLI Commands
|
||||
|
||||
@@ -112,6 +114,8 @@ The Codex-native role roster is `studio-orchestrator`, `producer`, `market-analy
|
||||
|
||||
This preserves Claude Game Studio functional coverage without legacy underscore role IDs. `narrative-designer` remains a first-class Codex-native story/content owner.
|
||||
|
||||
Supported aliases remain intentional: `new` is an alias for `init`, and registered workflow shortcuts render their prompts. Unsupported upstream or legacy underscore role IDs are rejected.
|
||||
|
||||
## Project Layout
|
||||
|
||||
Repository assets:
|
||||
@@ -134,6 +138,8 @@ Generated project artifacts:
|
||||
- `documentation/`: generated game-design and workflow documents.
|
||||
- `source/project-<slug>/`: engine project location contract.
|
||||
|
||||
Generated role prompts and workflow files include deterministic freshness metadata and rendered-body hashes. Project validation checks new generated surfaces for stale registry inputs or manual body tampering, and reports legacy generated files without metadata as regeneration-needed skip diagnostics rather than silently treating them as fresh.
|
||||
|
||||
## Development
|
||||
|
||||
Use the repository scripts:
|
||||
|
||||
@@ -12,9 +12,9 @@ Legacy validation depended on Python and shell assumptions. This port is TypeScr
|
||||
|
||||
Role roster coverage is preserved through Codex-native IDs: `studio-orchestrator`, `market-analyst`, `data-scientist`, senior design/art roles, game-feel, UI/UX, QA, release, and implementation roles. Legacy underscore aliases such as `producer_agent`, `qa_agent`, and `master_orchestrator` are intentionally not valid role IDs.
|
||||
|
||||
Generated projects materialize project-specific `.codex/prompts/<role>.md` files for every role. `AGENTS.md` remains the primary generated Codex instruction surface and is owned by `src/agents.ts`.
|
||||
Generated projects materialize project-specific `.codex/prompts/<role>.md` files for every role. `run <role>` inlines the current generated project role prompt instead of falling back to the package role prompt. `AGENTS.md` remains the primary generated Codex instruction surface and is owned by `src/agents.ts`.
|
||||
|
||||
Market and analytics are first-class renderable workflows owned by dedicated roles. Their prompts inline the selected package template bodies instead of pointing Codex at project-relative template paths.
|
||||
Market and analytics are first-class renderable workflows owned by dedicated roles. Workflow prompts and normal role runs inline selected package template bodies instead of pointing Codex at project-relative template paths or loading every template.
|
||||
|
||||
Studio orchestration is provided by the `studio-orchestrator` role and the render-only `handoff` workflow shortcut, not by a generated `project_orchestrator.md`.
|
||||
|
||||
@@ -22,6 +22,8 @@ Richer workflows exist for design specs, game-feel review, art direction, UI/UX
|
||||
|
||||
Intentional omissions for the first build: no interactive `menu`, no `startover`, no generated `project_orchestrator.md`, no exact `template_info.md`, no eager competitor reports during init, and no upstream license/authorship/citation parity documents.
|
||||
|
||||
Codex-native difference: `run <role>` invokes `codex exec` by default against the generated bounded prompt packet. `--dry-run` and `--print-prompt` are the non-executing inspection paths.
|
||||
Codex-native difference: `run <role>` invokes `codex exec` by default against the generated bounded prompt packet. `--dry-run` and `--print-prompt` are the non-executing inspection paths. `--allow-broad-context` performs bounded discovery of existing project artifacts rather than recursive ingestion, and `--fix` receives the same generated role prompt and selected templates as the primary implementation prompt.
|
||||
|
||||
Future-only features are not implemented in this build: planner/`next`, telemetry, parallel orchestration, changed-file tracking, prompt-size metrics, and hard output-ownership enforcement.
|
||||
Generated role prompts and workflow files include freshness metadata and rendered-body hashes for new projects. Validation reports legacy missing-metadata files as regeneration-needed skip diagnostics instead of treating them as fresh.
|
||||
|
||||
Future-only features are not implemented in this build: planner/`next`, telemetry, parallel orchestration, changed-file tracking, prompt-size metrics, hard output-ownership enforcement, legacy `.gamestudio` compatibility, `CODEX.md`, and `project_orchestrator.md`.
|
||||
|
||||
@@ -7,8 +7,10 @@ npm exec open-gamestudio -- init --name "My Game" --engine godot --mode prototyp
|
||||
npm exec open-gamestudio -- run producer --project projects/my-game "Create the initial market overview."
|
||||
```
|
||||
|
||||
For inspection-only runs, add `--dry-run` or `--print-prompt` to view the generated Codex prompt packet and metadata path before execution.
|
||||
For inspection-only runs, add `--dry-run` or `--print-prompt` to view the generated Codex prompt packet and metadata path before execution. Role runs inline the generated project role prompt and only selected package templates; workflow shortcuts still render prompts only.
|
||||
|
||||
Intentional differences: no interactive menu, no `startover`, no exact `template_info.md`, no eager competitor reports during init, and no generated `project_orchestrator.md`.
|
||||
`--allow-broad-context` performs bounded discovery of existing project artifacts. New generated prompts and workflows carry freshness metadata and body hashes; older generated files without metadata validate with regeneration-needed skip diagnostics.
|
||||
|
||||
Intentional differences: no interactive menu, no `startover`, no exact `template_info.md`, no eager competitor reports during init, no generated `project_orchestrator.md`, no `CODEX.md`, no legacy `.gamestudio` compatibility, and no unsupported upstream underscore role IDs. Supported aliases such as `new` for `init` remain available.
|
||||
|
||||
Future-only features are not implemented: `open-gamestudio next`, telemetry, parallel orchestration, changed-file tracking, and ownership enforcement.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
status: active
|
||||
doc_type: behavior
|
||||
truth_kind: behavior
|
||||
last_reviewed: 2026-05-28
|
||||
last_reviewed: 2026-05-30
|
||||
source_of_truth:
|
||||
- ../../truthmark/areas/repository.md
|
||||
---
|
||||
@@ -26,7 +26,9 @@ This doc was created from the editable behavior-doc template at docs/templates/b
|
||||
- Codex session prompts render the role display name, role ID, phase, project root, objective, engine context, context files, expected outputs, verification command, review checklist, and completion-report instructions.
|
||||
- The workflow registry defines vertical-slice, bugfix, playtest, market-analysis, analytics-setup, design-spec, game-feel-tuning, art-direction, ui-ux-review, production-milestone, handoff, review, and ship-check workflow prompts.
|
||||
- Selected workflows include CLI aliases for render-only shortcuts, including market, analytics, design-spec, feel-review, art-direction, ui-review, milestone, and handoff.
|
||||
- Template selection is task- and role-sensitive; template files are read from package assets and embedded into applicable workflow prompts.
|
||||
- Template selection is task- and role-sensitive; template files are read from package assets and embedded into applicable workflow prompts and role-run prompts.
|
||||
- Market analyst, data scientist, game-feel, UI/UX, QA, and release roles have bounded default templates; other additions come from role-specific keyword rules.
|
||||
- Generated workflow files carry deterministic source-input and rendered-body hash metadata that covers workflow definition fields and the owning role display name, expected outputs, and review checklist used in the rendered workflow body.
|
||||
|
||||
## Core Rules
|
||||
|
||||
@@ -34,6 +36,7 @@ This doc was created from the editable behavior-doc template at docs/templates/b
|
||||
- Prompt rendering must include the role display name and project/session metadata needed by Codex to operate without hidden state.
|
||||
- Templates that require Markdown sections must have non-empty required sections; the project config template must parse as JSON.
|
||||
- Workflow shortcuts render prompts; they do not imply hidden parallel orchestration or future planner behavior.
|
||||
- Workflow prompt rendering may append only selected workflow templates and must not load every template.
|
||||
|
||||
## Flows And States
|
||||
|
||||
@@ -44,7 +47,7 @@ This doc was created from the editable behavior-doc template at docs/templates/b
|
||||
|
||||
- Role IDs are stable strings exported from `src/roles.ts` and reused by config validation, project state, prompt generation, workflow routing, and task creation.
|
||||
- Workflow IDs map to `.codex/workflows/<workflow>.md` files and expected context-file lists.
|
||||
- Template IDs map to package template paths, role applicability, tags, and required-section validation.
|
||||
- Template IDs map to package template paths, role applicability, tags, and required-section validation. Current reusable templates include GDD, feature spec, handoff, analytics setup, engine setup, market analysis, project config, game-feel tuning, art direction, UI/UX review, production milestone, playtest report, and ship check.
|
||||
|
||||
## Product Decisions
|
||||
|
||||
|
||||
@@ -25,23 +25,24 @@ This bounded leaf truth doc owns `run` preparation and execution, Codex command
|
||||
|
||||
## Inputs
|
||||
|
||||
- A valid project root with `.codex/studio.json`.
|
||||
- A valid project root with `.codex/studio.json` and the generated role prompt for the requested role.
|
||||
- A studio role ID or task ID.
|
||||
- A non-empty task/objective.
|
||||
- Optional included artifacts, verification command/args, review flag, fix flag, and max fix-pass count.
|
||||
|
||||
## Execution Model
|
||||
|
||||
- `prepareRun` resolves the project, reads studio state, renders a Codex prompt, computes prompt and metadata cache paths, and builds Codex execution commands.
|
||||
- `prepareRun` resolves the project, reads studio state, inlines the generated project role prompt plus selected package templates, renders a Codex prompt, computes prompt and metadata cache paths, and builds Codex execution commands.
|
||||
- `--print-prompt` and `--dry-run` are inspection-only paths and do not write prompt cache, metadata, task state, or run directories.
|
||||
- `--allow-broad-context` adds an explicit bounded list of existing regular project files such as the GDD, production timeline, market overview, `AGENTS.md`, and `.codex/studio.json`; discovered files must resolve under the project root.
|
||||
- Non-dry runs write prompt and metadata before executing Codex.
|
||||
- Implementation and fix passes use a workspace-write Codex sandbox; review passes use a read-only Codex sandbox.
|
||||
- Implementation and fix passes use a workspace-write Codex sandbox; review passes use a read-only Codex sandbox. Fix prompts receive the same generated role prompt and selected package templates as the primary implementation prompt, and review prompts receive the generated QA playtester prompt plus selected QA templates.
|
||||
- Task runs mutate task status only for non-dry execution.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Validate the requested role/task and project state.
|
||||
2. Build context-file lists and render the Codex prompt.
|
||||
2. Build context-file lists, discover bounded broad context when requested, and render the Codex prompt.
|
||||
3. For non-dry runs, write the prompt and metadata cache under `.codex/runs/`.
|
||||
4. Check Codex availability before execution through the CLI path.
|
||||
5. Execute the implementation prompt.
|
||||
@@ -70,6 +71,7 @@ This bounded leaf truth doc owns `run` preparation and execution, Codex command
|
||||
- Decision (2026-05-28): Make dry-run and print-prompt non-mutating inspection paths.
|
||||
- Decision (2026-05-28): Force review prompts through a read-only sandbox while implementation/fix prompts retain workspace-write behavior.
|
||||
- Decision (2026-05-28): Require a valid project before task-store writes.
|
||||
- Decision (2026-05-30): Treat generated project role prompts and selected package templates as runtime prompt input for role execution while keeping broad context discovery bounded.
|
||||
|
||||
## Rationale
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
status: active
|
||||
doc_type: contract
|
||||
truth_kind: contract
|
||||
last_reviewed: 2026-05-28
|
||||
last_reviewed: 2026-05-30
|
||||
source_of_truth:
|
||||
- ../../truthmark/areas/repository.md
|
||||
---
|
||||
@@ -41,7 +41,8 @@ This bounded leaf truth doc owns the repository CLI command contract, package sc
|
||||
|
||||
- Unknown roles fail with a message naming Codex-native hyphenated role IDs.
|
||||
- Missing package scripts, missing package bin/files, missing source files, unavailable Codex CLI, invalid templates, exposed future surfaces, missing build output, and package smoke failures are validation failures.
|
||||
- Project validation fails for invalid `.codex/studio.json`, missing generated project files, missing workflow/prompt sections, forbidden generated surfaces, or read-only command mutations.
|
||||
- Project validation fails for invalid `.codex/studio.json`, missing generated project files, missing workflow/prompt sections, stale or tampered generated-surface metadata, current-renderer mismatches, malformed or incomplete generated-surface metadata markers, forbidden generated surfaces, or read-only command mutations.
|
||||
- Project validation reports generated prompt or workflow files with no generated-surface metadata markers as legacy skip diagnostics that require regeneration before relying on freshness checks.
|
||||
|
||||
## Compatibility Rules
|
||||
|
||||
@@ -59,6 +60,7 @@ This bounded leaf truth doc owns the repository CLI command contract, package sc
|
||||
|
||||
- Decision (2026-05-28): Keep `validate` as the hard-failing parity gate before claiming repository or project readiness.
|
||||
- Decision (2026-05-28): Document and test that future planner/telemetry/parallel/ownership surfaces are not exposed by the CLI.
|
||||
- Decision (2026-05-30): Validate generated-surface source metadata, rendered-body hashes, and current renderer output for new generated prompt/workflow files, fail malformed or partial generated metadata with the stable freshness/body check IDs, and treat only fully absent metadata as a legacy skip diagnostic.
|
||||
|
||||
## Rationale
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ Code surface:
|
||||
- src/config.ts
|
||||
- src/engines.ts
|
||||
- src/agents.ts
|
||||
- src/generated-surfaces.ts
|
||||
- src/paths.ts
|
||||
- engine_configs/**
|
||||
- tests/project-workflow.test.ts
|
||||
@@ -48,6 +49,7 @@ Code surface:
|
||||
- src/codex-prompts.ts
|
||||
- src/workflows.ts
|
||||
- src/templates.ts
|
||||
- src/generated-surfaces.ts
|
||||
- templates/**
|
||||
- tests/roles.test.ts
|
||||
- tests/codex-session.test.ts
|
||||
@@ -69,6 +71,7 @@ truth_documents:
|
||||
|
||||
Code surface:
|
||||
- src/runner.ts
|
||||
- src/context.ts
|
||||
- src/tasks.ts
|
||||
- src/codex-runtime.ts
|
||||
- src/verification.ts
|
||||
@@ -93,6 +96,7 @@ truth_documents:
|
||||
Code surface:
|
||||
- src/cli.ts
|
||||
- src/validation.ts
|
||||
- src/generated-surfaces.ts
|
||||
- tests/validation.test.ts
|
||||
- tests/functionality-gap-pass.test.ts
|
||||
|
||||
|
||||
+43
-1
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { guidanceConfigHash, type ProjectConfig } from "./config.js";
|
||||
import type { EngineConfigRegistry } from "./engines.js";
|
||||
import { renderGeneratedSurfaceMetadata } from "./generated-surfaces.js";
|
||||
import { rolePackages, studioRoleIds, type StudioRoleId } from "./roles.js";
|
||||
|
||||
export type MaterializeAgentsInput = {
|
||||
@@ -23,6 +24,12 @@ export function readAgentPrompt(agent: StudioRoleId, projectRoot?: string): stri
|
||||
return rolePackages[agent].systemPrompt;
|
||||
}
|
||||
|
||||
export function readProjectAgentPrompt(agent: StudioRoleId, projectRoot: string): string {
|
||||
const projectPrompt = path.join(projectRoot, ".codex", "prompts", `${agent}.md`);
|
||||
if (!existsSync(projectPrompt)) throw new Error(`Missing generated project role prompt: ${path.relative(projectRoot, projectPrompt)}`);
|
||||
return readFileSync(projectPrompt, "utf8");
|
||||
}
|
||||
|
||||
export const projectAgentsMdRequiredSections = [
|
||||
"## Project Goal",
|
||||
"## Engine",
|
||||
@@ -90,7 +97,7 @@ Do not use telemetry, planner/next, parallel orchestration, or ownership enforce
|
||||
export function renderProjectRolePrompt(role: StudioRoleId, config: ProjectConfig, engines: EngineConfigRegistry): string {
|
||||
const pkg = rolePackages[role];
|
||||
const engine = engines[config.project.engine];
|
||||
return [
|
||||
const body = [
|
||||
`# ${pkg.displayName}`,
|
||||
"",
|
||||
`Project: ${config.project.name}`,
|
||||
@@ -132,6 +139,41 @@ export function renderProjectRolePrompt(role: StudioRoleId, config: ProjectConfi
|
||||
pkg.handoffTemplate,
|
||||
""
|
||||
].join("\n");
|
||||
return `${renderGeneratedSurfaceMetadata({
|
||||
surface: "role-prompt",
|
||||
role,
|
||||
sourceInput: projectRolePromptSourceInput(role, config, engines),
|
||||
body
|
||||
})}${body}`;
|
||||
}
|
||||
|
||||
export function projectRolePromptSourceInput(role: StudioRoleId, config: ProjectConfig, engines: EngineConfigRegistry): unknown {
|
||||
const pkg = rolePackages[role];
|
||||
const engine = engines[config.project.engine];
|
||||
return {
|
||||
role,
|
||||
displayName: pkg.displayName,
|
||||
systemPrompt: pkg.systemPrompt,
|
||||
expectedOutputs: pkg.expectedOutputs,
|
||||
reviewChecklist: pkg.reviewChecklist,
|
||||
handoffTemplate: pkg.handoffTemplate,
|
||||
engineDisplayName: engine.display_name,
|
||||
engineHints: engine.codex_hints,
|
||||
project: {
|
||||
name: config.project.name,
|
||||
slug: config.project.slug,
|
||||
mode: config.project.mode,
|
||||
engine: config.project.engine,
|
||||
engineVersion: config.project.engine_version,
|
||||
concept: config.project.concept,
|
||||
genre: config.project.genre,
|
||||
platform: config.project.platform,
|
||||
audience: config.project.audience,
|
||||
monetization: config.project.monetization,
|
||||
timeline: config.project.timeline,
|
||||
competitors: config.project.competitors
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function materializeAgents(input: MaterializeAgentsInput): string[] {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { existsSync, realpathSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const broadContextCandidates = [
|
||||
"documentation/design/gdd.md",
|
||||
"documentation/production/timeline.md",
|
||||
"resources/market-research/market-overview.md",
|
||||
"AGENTS.md",
|
||||
".codex/studio.json"
|
||||
] as const;
|
||||
|
||||
export function discoverBroadContextFiles(projectRoot: string, existing: string[] = []): string[] {
|
||||
const seen = new Set(existing);
|
||||
const realRoot = realpathSync(projectRoot);
|
||||
return broadContextCandidates.filter((file) => {
|
||||
if (seen.has(file)) return false;
|
||||
const full = path.join(projectRoot, file);
|
||||
if (!existsSync(full)) return false;
|
||||
const realFull = realpathSync(full);
|
||||
return statSync(realFull).isFile() && realFull.startsWith(`${realRoot}${path.sep}`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export type GeneratedSurfaceMetadata = {
|
||||
surface: string;
|
||||
id?: string;
|
||||
role?: string;
|
||||
schema: string;
|
||||
sourceInputSha256: string;
|
||||
renderedBodySha256: string;
|
||||
};
|
||||
|
||||
export type GeneratedSurfaceMetadataParts = {
|
||||
generated?: Pick<GeneratedSurfaceMetadata, "surface" | "id" | "role" | "schema">;
|
||||
sourceInputSha256?: string;
|
||||
renderedBodySha256?: string;
|
||||
hasAnyMarker: boolean;
|
||||
};
|
||||
|
||||
export function stableStringify(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
||||
if (value && typeof value === "object") {
|
||||
return `{${Object.keys(value as Record<string, unknown>)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${stableStringify((value as Record<string, unknown>)[key])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function stableHash(value: unknown): string {
|
||||
return createHash("sha256").update(stableStringify(value)).digest("hex");
|
||||
}
|
||||
|
||||
export function hashGeneratedBody(bodyWithoutMetadata: string): string {
|
||||
return createHash("sha256").update(`${bodyWithoutMetadata.trimEnd()}\n`).digest("hex");
|
||||
}
|
||||
|
||||
export function stripGeneratedMetadata(body: string): string {
|
||||
return body.replace(
|
||||
/^<!-- generated-by: open-gamestudio surface=.* -->\n<!-- source-input-sha256: .* -->\n<!-- rendered-body-sha256: .* -->\n/,
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
export function renderGeneratedSurfaceMetadata(args: { surface: string; id?: string; role?: string; sourceInput: unknown; body: string }): string {
|
||||
const target = [args.id ? `id=${args.id}` : undefined, args.role ? `role=${args.role}` : undefined].filter(Boolean).join(" ");
|
||||
return [
|
||||
`<!-- generated-by: open-gamestudio surface=${args.surface}${target ? ` ${target}` : ""} schema=1.0 -->`,
|
||||
`<!-- source-input-sha256: ${stableHash(args.sourceInput)} -->`,
|
||||
`<!-- rendered-body-sha256: ${hashGeneratedBody(args.body)} -->`,
|
||||
""
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function parseGeneratedSurfaceMetadata(body: string): GeneratedSurfaceMetadata | undefined {
|
||||
const parts = parseGeneratedSurfaceMetadataParts(body);
|
||||
if (!parts.generated || !parts.sourceInputSha256 || !parts.renderedBodySha256) return undefined;
|
||||
return { ...parts.generated, sourceInputSha256: parts.sourceInputSha256, renderedBodySha256: parts.renderedBodySha256 };
|
||||
}
|
||||
|
||||
export function parseGeneratedSurfaceMetadataParts(body: string): GeneratedSurfaceMetadataParts {
|
||||
const generated = /^<!-- generated-by: open-gamestudio surface=(\S+)(?: id=(\S+))?(?: role=(\S+))? schema=(\S+) -->\n/.exec(body);
|
||||
const source = /^<!-- generated-by: open-gamestudio surface=.* -->\n<!-- source-input-sha256: ([a-f0-9]+) -->\n/.exec(body);
|
||||
const rendered = /^<!-- generated-by: open-gamestudio surface=.* -->\n<!-- source-input-sha256: [a-f0-9]+ -->\n<!-- rendered-body-sha256: ([a-f0-9]+) -->\n/.exec(body);
|
||||
const hasAnyMarker = /^<!-- generated-by: open-gamestudio\b.*-->$/m.test(body) || /^<!-- source-input-sha256: .*-->$/m.test(body) || /^<!-- rendered-body-sha256: .*-->$/m.test(body);
|
||||
return {
|
||||
generated: generated ? { surface: generated[1], id: generated[2], role: generated[3], schema: generated[4] } : undefined,
|
||||
sourceInputSha256: source?.[1],
|
||||
renderedBodySha256: rendered?.[1],
|
||||
hasAnyMarker
|
||||
};
|
||||
}
|
||||
+32
-2
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { activeAgentsForMode, slugify, type ProjectConfig, type ProjectMode } from "./config.js";
|
||||
import { createEngineFolders, createEngineProjectFiles, loadEngineConfigs, normalizeEngine, projectClassName, sourceRoot, unrealProjectFileName } from "./engines.js";
|
||||
import { materializeAgents } from "./agents.js";
|
||||
import { renderGeneratedSurfaceMetadata } from "./generated-surfaces.js";
|
||||
import { packageAssetPath, resolveProjectRoot } from "./paths.js";
|
||||
import { rolePackages, studioRoleIds, type StudioRoleId } from "./roles.js";
|
||||
import { workflowIds, workflowRegistry, type WorkflowId } from "./workflows.js";
|
||||
@@ -31,6 +32,9 @@ export type StudioProjectState = {
|
||||
genre: string;
|
||||
platform: string;
|
||||
audience: string;
|
||||
competitors: string[];
|
||||
monetization: string;
|
||||
timeline: string;
|
||||
engine: ProjectConfig["project"]["engine"];
|
||||
engineVersion: string;
|
||||
mode: ProjectMode;
|
||||
@@ -131,6 +135,9 @@ export function studioStateFromConfig(config: ProjectConfig): StudioProjectState
|
||||
genre: config.project.genre,
|
||||
platform: config.project.platform,
|
||||
audience: config.project.audience,
|
||||
competitors: config.project.competitors,
|
||||
monetization: config.project.monetization,
|
||||
timeline: config.project.timeline,
|
||||
engine: config.project.engine,
|
||||
engineVersion: config.project.engine_version,
|
||||
mode: config.project.mode,
|
||||
@@ -158,10 +165,10 @@ function workflowTitle(id: WorkflowId): string {
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function workflowBody(workflow: WorkflowId): string {
|
||||
export function workflowBody(workflow: WorkflowId): string {
|
||||
const definition = workflowRegistry[workflow];
|
||||
const pkg = rolePackages[definition.role];
|
||||
return [
|
||||
const body = [
|
||||
`# ${workflowTitle(workflow)} Workflow`,
|
||||
"",
|
||||
"## Purpose",
|
||||
@@ -185,6 +192,29 @@ function workflowBody(workflow: WorkflowId): string {
|
||||
...pkg.reviewChecklist.map((item) => `- ${item}`),
|
||||
""
|
||||
].join("\n");
|
||||
return `${renderGeneratedSurfaceMetadata({
|
||||
surface: "workflow",
|
||||
id: workflow,
|
||||
sourceInput: workflowSourceInput(workflow),
|
||||
body
|
||||
})}${body}`;
|
||||
}
|
||||
|
||||
export function workflowSourceInput(workflow: WorkflowId): unknown {
|
||||
const definition = workflowRegistry[workflow];
|
||||
const pkg = rolePackages[definition.role];
|
||||
return {
|
||||
workflow,
|
||||
role: definition.role,
|
||||
phase: definition.phase,
|
||||
objective: definition.objective,
|
||||
file: definition.file,
|
||||
contextFiles: definition.contextFiles,
|
||||
templateIds: definition.templateIds ?? [],
|
||||
roleDisplayName: pkg.displayName,
|
||||
expectedOutputs: pkg.expectedOutputs,
|
||||
reviewChecklist: pkg.reviewChecklist
|
||||
};
|
||||
}
|
||||
|
||||
function writeCodexWorkflowFiles(projectRoot: string): void {
|
||||
|
||||
+25
-13
@@ -1,12 +1,15 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { readProjectAgentPrompt } from "./agents.js";
|
||||
import { buildCodexExecArgs, resolveCodexCommand, type CodexExecutionResult } from "./codex-runtime.js";
|
||||
import { renderCodexPrompt } from "./codex-prompts.js";
|
||||
import { createCodexStudioSession, type VerificationCommand } from "./codex-session.js";
|
||||
import { discoverBroadContextFiles } from "./context.js";
|
||||
import { readStudioProject } from "./projects.js";
|
||||
import { isStudioRoleId, unknownStudioRoleMessage, type StudioRoleId } from "./roles.js";
|
||||
import { resolveProjectRoot } from "./paths.js";
|
||||
import { renderSelectedTemplates, selectTemplates } from "./templates.js";
|
||||
import { runVerificationCommand, type VerificationResult } from "./verification.js";
|
||||
|
||||
export type RunOptions = {
|
||||
@@ -100,14 +103,21 @@ export function executeCodexRun(run: PreparedRun): CodexExecutionResult {
|
||||
return executeCodexPromptSync(run, run.prompt);
|
||||
}
|
||||
|
||||
function safeArtifact(projectRoot: string, artifact: string): string {
|
||||
function safeArtifact(projectRoot: string, artifact: string): { full: string; display: string } {
|
||||
if (/[\u0000-\u001f\u007f]/.test(artifact)) throw new Error("--include-artifact cannot contain control characters");
|
||||
if (path.isAbsolute(artifact)) throw new Error("--include-artifact must be relative");
|
||||
const full = path.resolve(projectRoot, artifact);
|
||||
if (!full.startsWith(`${projectRoot}${path.sep}`)) throw new Error("--include-artifact cannot escape the project root");
|
||||
const realRoot = realpathSync(projectRoot);
|
||||
const realFull = realpathSync(full);
|
||||
if (realFull !== realRoot && !realFull.startsWith(`${realRoot}${path.sep}`)) throw new Error("--include-artifact cannot escape the project root");
|
||||
return full;
|
||||
return { full: realFull, display: path.relative(realRoot, realFull).split(path.sep).join("/") };
|
||||
}
|
||||
|
||||
function renderRuntimeContextBlock(role: StudioRoleId, projectRoot: string, task: string): string {
|
||||
const projectRolePrompt = readProjectAgentPrompt(role, projectRoot);
|
||||
const templateBodies = renderSelectedTemplates(selectTemplates(role, task));
|
||||
return ["", `# Project Role Prompt: .codex/prompts/${role}.md`, "", projectRolePrompt.trim(), templateBodies, ""].join("\n");
|
||||
}
|
||||
|
||||
function executionFailed(result: CodexExecutionResult): boolean {
|
||||
@@ -238,11 +248,11 @@ export function prepareRun(roleInput: string, options: RunOptions, cwd = process
|
||||
const studio = readStudioProject(projectRoot);
|
||||
const contextFiles = ["AGENTS.md", ".codex/studio.json", `.codex/prompts/${role}.md`];
|
||||
const artifactBodies = (options.includeArtifact ?? []).map((artifact) => {
|
||||
const full = safeArtifact(projectRoot, artifact);
|
||||
contextFiles.push(artifact);
|
||||
return `\n\n# Included Artifact: ${artifact}\n\n${readFileSync(full, "utf8")}`;
|
||||
const { full, display } = safeArtifact(projectRoot, artifact);
|
||||
contextFiles.push(display);
|
||||
return `\n\n# Included Artifact: ${display}\n\n${readFileSync(full, "utf8")}`;
|
||||
});
|
||||
if (options.allowBroadContext) contextFiles.push("Broad context explicitly allowed by CLI flag.");
|
||||
if (options.allowBroadContext) contextFiles.push(...discoverBroadContextFiles(projectRoot, contextFiles));
|
||||
const maxFixPasses = options.maxFixPasses ?? 1;
|
||||
if (!Number.isFinite(maxFixPasses) || maxFixPasses < 0) throw new Error("--max-fix-passes must be a finite non-negative number");
|
||||
const session = createCodexStudioSession({
|
||||
@@ -254,26 +264,28 @@ export function prepareRun(roleInput: string, options: RunOptions, cwd = process
|
||||
contextFiles,
|
||||
verification: options.verifyCommand
|
||||
});
|
||||
const prompt = `${renderCodexPrompt(session)}${artifactBodies.join("")}`;
|
||||
const runtimeContextBlock = renderRuntimeContextBlock(role, projectRoot, task);
|
||||
const prompt = `${renderCodexPrompt(session)}${runtimeContextBlock}${artifactBodies.join("")}`;
|
||||
const reviewObjective = `Review the implementation for: ${task}. Inspect the diff and verification output. Return only JSON with blockers, warnings, summary, and needsFix.`;
|
||||
const reviewPrompt = options.review
|
||||
? renderCodexPrompt(
|
||||
? `${renderCodexPrompt(
|
||||
createCodexStudioSession({
|
||||
projectRoot,
|
||||
role: "qa-playtester",
|
||||
objective: `Review the implementation for: ${task}. Inspect the diff and verification output. Return only JSON with blockers, warnings, summary, and needsFix.`,
|
||||
objective: reviewObjective,
|
||||
phase: "review",
|
||||
engine: studio.engine,
|
||||
contextFiles: ["AGENTS.md", ".codex/studio.json"],
|
||||
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/prompts/qa-playtester.md"],
|
||||
expectedOutputs: ["Review JSON", "Blockers", "Warnings"],
|
||||
allowFileEdits: false,
|
||||
sandbox: "read-only",
|
||||
reviewMode: "diff"
|
||||
})
|
||||
)
|
||||
)}${renderRuntimeContextBlock("qa-playtester", projectRoot, reviewObjective)}`
|
||||
: undefined;
|
||||
const fixPrompt =
|
||||
options.fix && maxFixPasses > 0
|
||||
? renderCodexPrompt(
|
||||
? `${renderCodexPrompt(
|
||||
createCodexStudioSession({
|
||||
projectRoot,
|
||||
role,
|
||||
@@ -284,7 +296,7 @@ export function prepareRun(roleInput: string, options: RunOptions, cwd = process
|
||||
verification: options.verifyCommand,
|
||||
expectedOutputs: ["Fix changes", "Verification results", "Remaining blockers"]
|
||||
})
|
||||
)
|
||||
)}${runtimeContextBlock}`
|
||||
: undefined;
|
||||
const runId = `${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 17)}-${process.pid}-${++runSequence}`;
|
||||
const runDir = path.join(projectRoot, ".codex", "runs", `${runId}-${role}`);
|
||||
|
||||
+81
-1
@@ -10,7 +10,13 @@ export type TemplateId =
|
||||
| "analytics_setup"
|
||||
| "engine_setup"
|
||||
| "market_analysis"
|
||||
| "project_config";
|
||||
| "project_config"
|
||||
| "game_feel_tuning"
|
||||
| "art_direction"
|
||||
| "ui_ux_review"
|
||||
| "production_milestone"
|
||||
| "playtest_report"
|
||||
| "ship_check";
|
||||
|
||||
export type TemplateInfo = {
|
||||
id: TemplateId;
|
||||
@@ -77,6 +83,54 @@ export const templateRegistry: Record<TemplateId, TemplateInfo> = {
|
||||
roles: ["producer", "creative-director"],
|
||||
tags: ["config", "setup"],
|
||||
requiredSections: []
|
||||
},
|
||||
game_feel_tuning: {
|
||||
id: "game_feel_tuning",
|
||||
category: "design",
|
||||
path: "templates/game_feel_tuning_template.md",
|
||||
roles: ["game-feel-designer", "qa-playtester"],
|
||||
tags: ["feel", "controls", "tuning"],
|
||||
requiredSections: ["# Purpose", "# Inputs", "# Outputs", "# Validation"]
|
||||
},
|
||||
art_direction: {
|
||||
id: "art_direction",
|
||||
category: "art",
|
||||
path: "templates/art_direction_template.md",
|
||||
roles: ["senior-game-artist", "technical-artist", "creative-director"],
|
||||
tags: ["art", "visual", "style"],
|
||||
requiredSections: ["# Purpose", "# Inputs", "# Outputs", "# Validation"]
|
||||
},
|
||||
ui_ux_review: {
|
||||
id: "ui_ux_review",
|
||||
category: "ui",
|
||||
path: "templates/ui_ux_review_template.md",
|
||||
roles: ["ui-ux-designer", "qa-playtester"],
|
||||
tags: ["ui", "ux", "usability"],
|
||||
requiredSections: ["# Purpose", "# Inputs", "# Outputs", "# Validation"]
|
||||
},
|
||||
production_milestone: {
|
||||
id: "production_milestone",
|
||||
category: "production",
|
||||
path: "templates/production_milestone_template.md",
|
||||
roles: ["producer", "studio-orchestrator"],
|
||||
tags: ["milestone", "scope", "schedule"],
|
||||
requiredSections: ["# Purpose", "# Inputs", "# Outputs", "# Validation"]
|
||||
},
|
||||
playtest_report: {
|
||||
id: "playtest_report",
|
||||
category: "qa",
|
||||
path: "templates/playtest_report_template.md",
|
||||
roles: ["qa-playtester"],
|
||||
tags: ["playtest", "qa", "report"],
|
||||
requiredSections: ["# Purpose", "# Inputs", "# Outputs", "# Validation"]
|
||||
},
|
||||
ship_check: {
|
||||
id: "ship_check",
|
||||
category: "release",
|
||||
path: "templates/ship_check_template.md",
|
||||
roles: ["release-manager", "producer"],
|
||||
tags: ["ship", "release", "readiness"],
|
||||
requiredSections: ["# Purpose", "# Inputs", "# Outputs", "# Validation"]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -136,9 +190,28 @@ export function validateTemplateFiles(): string[] {
|
||||
return failures;
|
||||
}
|
||||
|
||||
export function renderSelectedTemplates(templateIds: TemplateId[], heading = "## Selected Templates"): string {
|
||||
if (templateIds.length === 0) return "";
|
||||
return [
|
||||
"",
|
||||
heading,
|
||||
"",
|
||||
...templateIds.flatMap((id) => {
|
||||
const info = templateRegistry[id];
|
||||
return [`### Template: ${id}`, `Source: package:${info.path}`, "", readTemplate(id).trim(), ""];
|
||||
})
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function selectTemplates(agent: AgentName, task: string): TemplateId[] {
|
||||
const lower = task.toLowerCase();
|
||||
const selected = new Set<TemplateId>();
|
||||
if (agent === "market-analyst") selected.add("market_analysis");
|
||||
if (agent === "data-scientist") selected.add("analytics_setup");
|
||||
if (agent === "game-feel-designer") selected.add("game_feel_tuning");
|
||||
if (agent === "ui-ux-designer") selected.add("ui_ux_review");
|
||||
if (agent === "qa-playtester") selected.add("playtest_report");
|
||||
if (agent === "release-manager") selected.add("ship_check");
|
||||
if (/(handoff|coordination|coordinate)/.test(lower)) selected.add("handoff");
|
||||
if ((agent === "producer" || agent === "market-analyst") && /(market|competitor|audience)/.test(lower)) selected.add("market_analysis");
|
||||
if ((agent === "producer" || agent === "data-scientist") && /(analytics|metric|telemetry)/.test(lower)) selected.add("analytics_setup");
|
||||
@@ -151,5 +224,12 @@ export function selectTemplates(agent: AgentName, task: string): TemplateId[] {
|
||||
selected.add("project_config");
|
||||
}
|
||||
if (agent === "qa-playtester" && /(spec review|review spec)/.test(lower)) selected.add("feature_spec");
|
||||
if (agent === "qa-playtester" && /(ui|ux|hud|menu|onboarding|accessibility|usability)/.test(lower)) selected.add("ui_ux_review");
|
||||
if (agent === "qa-playtester" && /(feel|control|movement|timing|camera|pacing)/.test(lower)) selected.add("game_feel_tuning");
|
||||
if ((agent === "senior-game-artist" || agent === "technical-artist" || agent === "creative-director") && /(art|visual|style|asset|direction)/.test(lower)) {
|
||||
selected.add("art_direction");
|
||||
}
|
||||
if ((agent === "producer" || agent === "studio-orchestrator") && /(milestone|scope|schedule|production)/.test(lower)) selected.add("production_milestone");
|
||||
if ((agent === "producer" || agent === "release-manager") && /(release|ship|readiness|package|launch)/.test(lower)) selected.add("ship_check");
|
||||
return [...selected];
|
||||
}
|
||||
|
||||
+77
-7
@@ -3,13 +3,14 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from "node:
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { activeAgentsForMode } from "./config.js";
|
||||
import { projectAgentsMdRequiredSections, validateBaseAgents } from "./agents.js";
|
||||
import { projectAgentsMdRequiredSections, projectRolePromptSourceInput, renderProjectRolePrompt, validateBaseAgents } from "./agents.js";
|
||||
import { checkCodexAvailability } from "./codex-runtime.js";
|
||||
import { createCodexStudioSession } from "./codex-session.js";
|
||||
import { renderCodexPrompt } from "./codex-prompts.js";
|
||||
import { loadEngineConfigs, sourceRoot, unrealProjectFileName } from "./engines.js";
|
||||
import { hashGeneratedBody, parseGeneratedSurfaceMetadataParts, stripGeneratedMetadata, stableHash } from "./generated-surfaces.js";
|
||||
import { packageAssetPath } from "./paths.js";
|
||||
import { readStudioProject, resumeProject, statusProject } from "./projects.js";
|
||||
import { readStudioProject, resumeProject, statusProject, workflowBody, workflowSourceInput, type StudioProjectState } from "./projects.js";
|
||||
import { rolePackages, studioRoleIds } from "./roles.js";
|
||||
import { templateRegistry, validateTemplateFiles } from "./templates.js";
|
||||
import { renderWorkflowPrompt, workflowIds, workflowRegistry } from "./workflows.js";
|
||||
@@ -25,6 +26,10 @@ function fail(id: string, message: string, file?: string): ValidationCheck {
|
||||
return { id, status: "fail", message, path: file };
|
||||
}
|
||||
|
||||
function skip(id: string, message: string, file?: string): ValidationCheck {
|
||||
return { id, status: "skip", message, path: file };
|
||||
}
|
||||
|
||||
function sectionHasContent(body: string, section: string): boolean {
|
||||
const escaped = section.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = new RegExp(`^${escaped}\\s*$`, "m").exec(body);
|
||||
@@ -35,13 +40,67 @@ function sectionHasContent(body: string, section: string): boolean {
|
||||
return (nextHeading === -1 ? rest : rest.slice(0, nextHeading)).trim().length > 0;
|
||||
}
|
||||
|
||||
function stablePromptSectionChecks(projectRoot: string, role: (typeof studioRoleIds)[number], projectName: string): ValidationCheck[] {
|
||||
function configFromStudio(studio: StudioProjectState) {
|
||||
return {
|
||||
schema_version: "1.0" as const,
|
||||
project: {
|
||||
name: studio.name,
|
||||
slug: studio.slug,
|
||||
concept: studio.concept,
|
||||
genre: studio.genre,
|
||||
platform: studio.platform,
|
||||
audience: studio.audience,
|
||||
competitors: studio.competitors ?? [],
|
||||
monetization: studio.monetization ?? "undecided",
|
||||
timeline: studio.timeline ?? "TBD",
|
||||
engine: studio.engine,
|
||||
engine_version: studio.engineVersion,
|
||||
mode: studio.mode,
|
||||
phase: studio.phase,
|
||||
status: studio.status
|
||||
},
|
||||
team: { active_agents: studio.activeRoles },
|
||||
production: { milestones: [] }
|
||||
};
|
||||
}
|
||||
|
||||
function generatedSurfaceChecks(args: { file: string; body: string; surface: string; id: string; target: { role?: string; id?: string }; sourceInput: unknown; expectedBody?: string }): ValidationCheck[] {
|
||||
const metadata = parseGeneratedSurfaceMetadataParts(args.body);
|
||||
if (!metadata.hasAnyMarker) {
|
||||
return [skip(args.id, "legacy generated surface lacks freshness metadata; regenerate before relying on freshness checks", args.file)];
|
||||
}
|
||||
const checks: ValidationCheck[] = [];
|
||||
const targetMatches =
|
||||
metadata.generated?.surface === args.surface && (!args.target.role || metadata.generated.role === args.target.role) && (!args.target.id || metadata.generated.id === args.target.id) && metadata.generated.schema === "1.0";
|
||||
const sourceHash = stableHash(args.sourceInput);
|
||||
checks.push(targetMatches && metadata.sourceInputSha256 === sourceHash ? pass(args.id, `${args.surface} source metadata is fresh`, args.file) : fail(args.id, `${args.surface} source metadata is stale`, args.file));
|
||||
const bodyId = args.id.replace(/\.freshness$/, ".body");
|
||||
const strippedBody = stripGeneratedMetadata(args.body);
|
||||
const bodyHash = hashGeneratedBody(strippedBody);
|
||||
const currentRendererBody = args.expectedBody ? stripGeneratedMetadata(args.expectedBody) === strippedBody : true;
|
||||
checks.push(metadata.renderedBodySha256 === bodyHash && currentRendererBody ? pass(bodyId, `${args.surface} body hash matches current renderer`, args.file) : fail(bodyId, `${args.surface} body hash mismatch`, args.file));
|
||||
return checks;
|
||||
}
|
||||
|
||||
function stablePromptSectionChecks(projectRoot: string, role: (typeof studioRoleIds)[number], studio: StudioProjectState): ValidationCheck[] {
|
||||
const file = path.join(projectRoot, ".codex", "prompts", `${role}.md`);
|
||||
if (!existsSync(file)) return [fail(`codex.role.${role}.prompt.exists`, `${role} prompt missing`, file), fail(`codex.prompt.${role}`, `${role} prompt missing`, file)];
|
||||
const body = readFileSync(file, "utf8");
|
||||
const checks = [pass(`codex.role.${role}.prompt.exists`, `${role} prompt exists`, file)];
|
||||
const engines = loadEngineConfigs(packageAssetPath("engine_configs"));
|
||||
checks.push(
|
||||
...generatedSurfaceChecks({
|
||||
file,
|
||||
body,
|
||||
surface: "role-prompt",
|
||||
id: `codex.role.${role}.prompt.freshness`,
|
||||
target: { role },
|
||||
sourceInput: projectRolePromptSourceInput(role, configFromStudio(studio), engines),
|
||||
expectedBody: renderProjectRolePrompt(role, configFromStudio(studio), engines)
|
||||
})
|
||||
);
|
||||
checks.push(body.trim().length > 0 ? pass(`codex.prompt.${role}`, `${role} prompt exists`, file) : fail(`codex.prompt.${role}`, `${role} prompt missing`, file));
|
||||
const projectLine = `Project: ${projectName}`;
|
||||
const projectLine = `Project: ${studio.name}`;
|
||||
checks.push(body.includes(projectLine) ? pass(`codex.role.${role}.prompt.project`, `${projectLine} exists`, file) : fail(`codex.role.${role}.prompt.project`, `${projectLine} missing`, file));
|
||||
const roleLine = `Role: ${rolePackages[role].displayName}`;
|
||||
checks.push(body.includes(roleLine) ? pass(`codex.role.${role}.prompt.role`, `${roleLine} exists`, file) : fail(`codex.role.${role}.prompt.role`, `${roleLine} missing`, file));
|
||||
@@ -123,7 +182,7 @@ export async function validateRepo(root = process.cwd()): Promise<ValidationChec
|
||||
}
|
||||
checks.push(pass(`codex.template.${id}.exists`, `${id} template exists`, file));
|
||||
}
|
||||
if (templateFailures.length === 0 && Object.keys(templateRegistry).length === 7) checks.push(pass("templates", "all templates exist"));
|
||||
if (templateFailures.length === 0) checks.push(pass("templates", "all templates exist"));
|
||||
|
||||
const cliHelp = existsSync(path.join(root, "src", "cli.ts")) ? execFileSync(path.join(root, "node_modules", ".bin", "tsx"), [path.join(root, "src", "cli.ts"), "--help"], { cwd: root, encoding: "utf8" }) : "";
|
||||
checks.push(/\bnext\b/.test(cliHelp) ? fail("codex.surface.future.next", "future next command exposed") : pass("codex.surface.future.next", "future next command is not exposed"));
|
||||
@@ -137,7 +196,7 @@ export async function validateRepo(root = process.cwd()): Promise<ValidationChec
|
||||
const packRaw = execFileSync("npm", ["pack", "--json"], { cwd: root, encoding: "utf8", shell: false });
|
||||
const packInfo = JSON.parse(packRaw)[0] as { filename: string; files: { path: string }[] };
|
||||
const packed = new Set(packInfo.files.map((file) => file.path));
|
||||
for (const need of ["dist/cli.js", "engine_configs/godot.json", "engine_configs/unity.json", "engine_configs/unreal.json", "templates/gdd_template.md", "templates/market_analysis_template.md", "templates/analytics_setup_template.md", "templates/handoff_template.md"]) {
|
||||
for (const need of ["dist/cli.js", "engine_configs/godot.json", "engine_configs/unity.json", "engine_configs/unreal.json", ...Object.values(templateRegistry).map((template) => template.path)]) {
|
||||
checks.push(packed.has(need) ? pass(`pack.${need}`, `${need} packed`) : fail(`pack.${need}`, `${need} missing from npm pack`));
|
||||
}
|
||||
const temp = mkdtempSync(path.join(tmpdir(), "open-gamestudio-pack-"));
|
||||
@@ -181,7 +240,7 @@ export function validateProject(projectRoot: string): ValidationCheck[] {
|
||||
checks.push(fail("project.agents_md", "AGENTS.md missing", agentsMd));
|
||||
}
|
||||
|
||||
for (const role of studioRoleIds) checks.push(...stablePromptSectionChecks(projectRoot, role, studio.name));
|
||||
for (const role of studioRoleIds) checks.push(...stablePromptSectionChecks(projectRoot, role, studio));
|
||||
|
||||
for (const workflow of workflowIds()) {
|
||||
const file = path.join(projectRoot, workflowRegistry[workflow].file);
|
||||
@@ -191,6 +250,17 @@ export function validateProject(projectRoot: string): ValidationCheck[] {
|
||||
}
|
||||
const body = readFileSync(file, "utf8");
|
||||
checks.push(pass(`codex.workflow.${workflow}.file.exists`, `${workflow} workflow exists`, file));
|
||||
checks.push(
|
||||
...generatedSurfaceChecks({
|
||||
file,
|
||||
body,
|
||||
surface: "workflow",
|
||||
id: `codex.workflow.${workflow}.freshness`,
|
||||
target: { id: workflow },
|
||||
sourceInput: workflowSourceInput(workflow),
|
||||
expectedBody: workflowBody(workflow)
|
||||
})
|
||||
);
|
||||
const hasSections = ["## Purpose", "## Inputs", "## Role", "## Outputs", "## Validation"].every((section) => sectionHasContent(body, section));
|
||||
checks.push(hasSections ? pass(`codex.workflow.${workflow}.sections`, `${workflow} workflow sections exist`, file) : fail(`codex.workflow.${workflow}.sections`, `${workflow} workflow sections missing content`, file));
|
||||
checks.push(renderWorkflowPrompt(projectRoot, workflow).includes(workflowRegistry[workflow].objective) ? pass(`codex.workflow.${workflow}.render`, `${workflow} workflow renders`) : fail(`codex.workflow.${workflow}.render`, `${workflow} workflow did not render`));
|
||||
|
||||
+10
-14
@@ -4,7 +4,7 @@ import { createCodexStudioSession, type CodexStudioPhase } from "./codex-session
|
||||
import { renderCodexPrompt } from "./codex-prompts.js";
|
||||
import type { EngineId } from "./engines.js";
|
||||
import type { StudioRoleId } from "./roles.js";
|
||||
import { readTemplate, templateRegistry, type TemplateId } from "./templates.js";
|
||||
import { renderSelectedTemplates, type TemplateId } from "./templates.js";
|
||||
|
||||
export type WorkflowId =
|
||||
| "vertical-slice"
|
||||
@@ -55,7 +55,8 @@ export const workflowRegistry: Record<WorkflowId, WorkflowDefinition> = {
|
||||
phase: "review",
|
||||
objective: "Inspect the current build, report reproducible playtest issues, and separate blockers from warnings.",
|
||||
file: ".codex/workflows/playtest.md",
|
||||
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/workflows/playtest.md"]
|
||||
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/workflows/playtest.md"],
|
||||
templateIds: ["playtest_report"]
|
||||
},
|
||||
"market-analysis": {
|
||||
id: "market-analysis",
|
||||
@@ -94,6 +95,7 @@ export const workflowRegistry: Record<WorkflowId, WorkflowDefinition> = {
|
||||
objective: "Review moment-to-moment feel, controls, feedback, pacing, and tuning risks with actionable changes.",
|
||||
file: ".codex/workflows/game-feel-tuning.md",
|
||||
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/workflows/game-feel-tuning.md"],
|
||||
templateIds: ["game_feel_tuning"],
|
||||
cliAlias: "feel-review"
|
||||
},
|
||||
"art-direction": {
|
||||
@@ -103,6 +105,7 @@ export const workflowRegistry: Record<WorkflowId, WorkflowDefinition> = {
|
||||
objective: "Define art direction, visual constraints, asset list, production risks, and review criteria.",
|
||||
file: ".codex/workflows/art-direction.md",
|
||||
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/workflows/art-direction.md"],
|
||||
templateIds: ["art_direction"],
|
||||
cliAlias: "art-direction"
|
||||
},
|
||||
"ui-ux-review": {
|
||||
@@ -112,6 +115,7 @@ export const workflowRegistry: Record<WorkflowId, WorkflowDefinition> = {
|
||||
objective: "Review UI flows, HUD/menu clarity, usability, onboarding, accessibility, and interaction risks.",
|
||||
file: ".codex/workflows/ui-ux-review.md",
|
||||
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/workflows/ui-ux-review.md"],
|
||||
templateIds: ["ui_ux_review"],
|
||||
cliAlias: "ui-review"
|
||||
},
|
||||
"production-milestone": {
|
||||
@@ -121,6 +125,7 @@ export const workflowRegistry: Record<WorkflowId, WorkflowDefinition> = {
|
||||
objective: "Convert current project state into milestone goals, task slices, risks, owners, and verification gates.",
|
||||
file: ".codex/workflows/production-milestone.md",
|
||||
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/workflows/production-milestone.md", "documentation/production/timeline.md"],
|
||||
templateIds: ["production_milestone"],
|
||||
cliAlias: "milestone"
|
||||
},
|
||||
handoff: {
|
||||
@@ -147,22 +152,13 @@ export const workflowRegistry: Record<WorkflowId, WorkflowDefinition> = {
|
||||
phase: "ship",
|
||||
objective: "Assess milestone readiness, package risk, validation status, and release blockers.",
|
||||
file: ".codex/workflows/ship-check.md",
|
||||
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/workflows/ship-check.md", "documentation/production/timeline.md"]
|
||||
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/workflows/ship-check.md", "documentation/production/timeline.md"],
|
||||
templateIds: ["ship_check"]
|
||||
}
|
||||
};
|
||||
|
||||
function renderWorkflowTemplates(workflow: WorkflowId): string {
|
||||
const templateIds = workflowRegistry[workflow].templateIds ?? [];
|
||||
if (templateIds.length === 0) return "";
|
||||
return [
|
||||
"",
|
||||
"## Workflow Templates",
|
||||
"",
|
||||
...templateIds.flatMap((id) => {
|
||||
const info = templateRegistry[id];
|
||||
return [`### Template: ${id}`, `Source: package:${info.path}`, "", readTemplate(id).trim(), ""];
|
||||
})
|
||||
].join("\n");
|
||||
return renderSelectedTemplates(workflowRegistry[workflow].templateIds ?? [], "## Workflow Templates");
|
||||
}
|
||||
|
||||
function readStudioEngine(projectRoot: string): EngineId {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Purpose
|
||||
|
||||
Define visual direction that keeps style, production capacity, and review criteria aligned.
|
||||
|
||||
# Inputs
|
||||
|
||||
- Project genre, audience, platform, and core fantasy.
|
||||
- Existing concept art, references, asset inventory, or engine constraints.
|
||||
- Production risks for asset creation, import, lighting, materials, and performance.
|
||||
|
||||
# Outputs
|
||||
|
||||
- Visual pillars and explicit style constraints.
|
||||
- Asset priority list with ownership and pipeline risks.
|
||||
- Review criteria for readability, consistency, and technical fit.
|
||||
|
||||
# Validation
|
||||
|
||||
- Check that proposed art direction can be produced within timeline and engine limits.
|
||||
- Confirm each visual recommendation maps to a gameplay, usability, or brand need.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Purpose
|
||||
|
||||
Tune moment-to-moment feel through bounded experiments across controls, timing, feedback, camera, and pacing.
|
||||
|
||||
# Inputs
|
||||
|
||||
- Current gameplay objective and target player fantasy.
|
||||
- Control scheme, camera setup, and movement/combat constraints.
|
||||
- Recent playtest notes or known feel complaints.
|
||||
|
||||
# Outputs
|
||||
|
||||
- Prioritized feel issues with suspected causes.
|
||||
- Small tuning experiments with expected player impact.
|
||||
- Acceptance criteria for improved responsiveness, readability, and pacing.
|
||||
|
||||
# Validation
|
||||
|
||||
- Verify each tuning change in play and record before/after observations.
|
||||
- Separate subjective preference from reproducible responsiveness or clarity issues.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Purpose
|
||||
|
||||
Capture playtest findings as reproducible observations with severity, interpretation, and next action.
|
||||
|
||||
# Inputs
|
||||
|
||||
- Build or branch under test and test scenario.
|
||||
- Device, platform, input method, and relevant settings.
|
||||
- Notes, recordings, logs, screenshots, or player quotes.
|
||||
|
||||
# Outputs
|
||||
|
||||
- Findings split into blockers, warnings, and observations.
|
||||
- Repro steps, expected behavior, actual behavior, and severity.
|
||||
- Follow-up questions or verification gaps.
|
||||
|
||||
# Validation
|
||||
|
||||
- Keep observations separate from interpretation.
|
||||
- Verify that blocker and warning labels reflect player impact and reproducibility.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Purpose
|
||||
|
||||
Turn project state into a bounded milestone plan with scope slices, dependencies, risks, and validation gates.
|
||||
|
||||
# Inputs
|
||||
|
||||
- Current project mode, timeline, and milestone target.
|
||||
- Known features, blockers, owners, and dependencies.
|
||||
- Required validation or release criteria.
|
||||
|
||||
# Outputs
|
||||
|
||||
- Milestone goal with explicit in-scope and out-of-scope work.
|
||||
- Task slices ordered by dependency and risk.
|
||||
- Validation gates, owner handoffs, and unresolved decisions.
|
||||
|
||||
# Validation
|
||||
|
||||
- Confirm each task has a clear owner, output, and verification path.
|
||||
- Check that milestone scope fits the stated timeline and risk budget.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Purpose
|
||||
|
||||
Assess package readiness, release blockers, known issues, rollback posture, and final validation status.
|
||||
|
||||
# Inputs
|
||||
|
||||
- Current milestone, release target, and package artifact.
|
||||
- Validation results, known issues, compliance needs, and deployment constraints.
|
||||
- Rollback or recovery plan if release fails.
|
||||
|
||||
# Outputs
|
||||
|
||||
- Ship, hold, or conditional ship recommendation.
|
||||
- Release blockers, warnings, known issues, and owner assignments.
|
||||
- Final checklist covering package, docs, compliance, and rollback readiness.
|
||||
|
||||
# Validation
|
||||
|
||||
- Confirm every blocker has an owner or explicit acceptance decision.
|
||||
- Verify package readiness against actual validation evidence, not intent.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Purpose
|
||||
|
||||
Review HUD, menus, onboarding, accessibility, and interaction flows for player clarity and usability.
|
||||
|
||||
# Inputs
|
||||
|
||||
- Current UI screens, HUD elements, and navigation flow.
|
||||
- Target inputs, platform constraints, and accessibility requirements.
|
||||
- Known player confusion, friction, or failed task reports.
|
||||
|
||||
# Outputs
|
||||
|
||||
- Usability findings grouped by blocker, warning, and polish.
|
||||
- Concrete UI flow or affordance changes with rationale.
|
||||
- Accessibility and input coverage notes.
|
||||
|
||||
# Validation
|
||||
|
||||
- Reproduce each finding with steps or observable screen state.
|
||||
- Verify text, controls, focus order, and feedback remain clear across supported viewports and inputs.
|
||||
@@ -21,20 +21,28 @@ describe("config, agents, and templates", () => {
|
||||
expect(validateTemplateFiles()).toEqual([]);
|
||||
expect(listTemplates().map((t) => t.id).sort()).toEqual([
|
||||
"analytics_setup",
|
||||
"art_direction",
|
||||
"engine_setup",
|
||||
"feature_spec",
|
||||
"game_feel_tuning",
|
||||
"gdd",
|
||||
"handoff",
|
||||
"market_analysis",
|
||||
"project_config"
|
||||
"playtest_report",
|
||||
"production_milestone",
|
||||
"project_config",
|
||||
"ship_check",
|
||||
"ui_ux_review"
|
||||
]);
|
||||
});
|
||||
|
||||
test("template selection is bounded", () => {
|
||||
expect(selectTemplates("producer", "Create market overview")).toEqual(["market_analysis"]);
|
||||
expect(selectTemplates("producer", "Create analytics plan")).toEqual(["analytics_setup"]);
|
||||
expect(selectTemplates("qa-playtester", "Review validation readiness")).toEqual([]);
|
||||
expect(selectTemplates("qa-playtester", "Review validation readiness")).toEqual(["playtest_report"]);
|
||||
expect(selectTemplates("producer", "handoff coordination")).toEqual(["handoff"]);
|
||||
expect(selectTemplates("qa-playtester", "Review UI usability")).toEqual(["playtest_report", "ui_ux_review"]);
|
||||
expect(selectTemplates("release-manager", "Check package")).toEqual(["ship_check"]);
|
||||
});
|
||||
|
||||
test("template show includes discoverability metadata before body", () => {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeAll, describe, expect, test } from "vitest";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const cli = path.join(repoRoot, "dist", "cli.js");
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
execFileSync("npm", ["run", "build", "--silent"], { cwd: repoRoot, encoding: "utf8" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
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 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 marketPrompt = execFileSync("node", [cli, "run", "market-analyst", "--project", projectRoot, "--print-prompt", "Assess competitors"], {
|
||||
cwd,
|
||||
encoding: "utf8"
|
||||
});
|
||||
expect(marketPrompt).toContain("# Project Role Prompt: .codex/prompts/market-analyst.md");
|
||||
expect(marketPrompt).toContain("Project: CLI Prompt Game");
|
||||
expect(marketPrompt).toContain("Template: market_analysis");
|
||||
expect(marketPrompt).not.toContain("Template: analytics_setup");
|
||||
|
||||
const dryRun = execFileSync("node", [cli, "run", "producer", "--project", projectRoot, "--dry-run", "--allow-broad-context", "Plan milestone"], {
|
||||
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.match(/- \.codex\/prompts\//g) ?? [])).toHaveLength(1);
|
||||
expect(existsSync(repoProject)).toBe(repoProjectExisted);
|
||||
} finally {
|
||||
if (!repoProjectExisted) rmSync(repoProject, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -83,11 +83,16 @@ describe("functionality gap pass", () => {
|
||||
expect(promptBody).toContain("Review Checklist");
|
||||
expect(promptBody).toContain("Handoff");
|
||||
expect(promptBody).toContain("Competitors: Mini Metro");
|
||||
expect(promptBody).toContain("source-input-sha256");
|
||||
expect(promptBody).toContain("rendered-body-sha256");
|
||||
|
||||
for (const workflow of Object.keys(workflowRegistry)) {
|
||||
expect(existsSync(path.join(projectRoot, workflowRegistry[workflow as keyof typeof workflowRegistry].file))).toBe(true);
|
||||
expect(renderWorkflowPrompt(projectRoot, workflow as keyof typeof workflowRegistry)).toContain(workflow);
|
||||
}
|
||||
const workflowBody = readFileSync(path.join(projectRoot, ".codex", "workflows", "ui-ux-review.md"), "utf8");
|
||||
expect(workflowBody).toContain("source-input-sha256");
|
||||
expect(workflowBody).toContain("rendered-body-sha256");
|
||||
expect(existsSync(path.join(projectRoot, "project_orchestrator.md"))).toBe(false);
|
||||
expect(existsSync(path.join(projectRoot, "CODEX.md"))).toBe(false);
|
||||
expect(existsSync(path.join(projectRoot, ".gamestudio", "runs"))).toBe(false);
|
||||
@@ -117,8 +122,15 @@ describe("functionality gap pass", () => {
|
||||
|
||||
expect(renderWorkflowPrompt(projectRoot, "design-spec")).toContain("Template: feature_spec");
|
||||
expect(renderWorkflowPrompt(projectRoot, "handoff")).toContain("Template: handoff");
|
||||
expect(renderWorkflowPrompt(projectRoot, "ui-ux-review")).not.toContain("## Workflow Templates");
|
||||
expect(renderWorkflowPrompt(projectRoot, "playtest")).toContain("Template: playtest_report");
|
||||
expect(renderWorkflowPrompt(projectRoot, "game-feel-tuning")).toContain("Template: game_feel_tuning");
|
||||
expect(renderWorkflowPrompt(projectRoot, "art-direction")).toContain("Template: art_direction");
|
||||
expect(renderWorkflowPrompt(projectRoot, "ui-ux-review")).toContain("Template: ui_ux_review");
|
||||
expect(renderWorkflowPrompt(projectRoot, "production-milestone")).toContain("Template: production_milestone");
|
||||
expect(renderWorkflowPrompt(projectRoot, "ship-check")).toContain("Template: ship_check");
|
||||
expect(renderWorkflowPrompt(projectRoot, "review")).not.toContain("## Workflow Templates");
|
||||
expect(renderWorkflowPrompt(projectRoot, "ui-ux-review")).not.toContain("Template: market_analysis");
|
||||
expect(renderWorkflowPrompt(projectRoot, "ship-check")).not.toContain("Template: analytics_setup");
|
||||
expect(market).not.toMatch(/- templates\/.*\.md/);
|
||||
});
|
||||
|
||||
|
||||
+110
-1
@@ -1,4 +1,4 @@
|
||||
import { chmodSync, existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
@@ -22,6 +22,113 @@ describe("runner", () => {
|
||||
expect(existsSync(printPrompt.metadataPath)).toBe(false);
|
||||
});
|
||||
|
||||
test("inlines generated project role prompt", () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-runner-"));
|
||||
const { projectRoot } = initProject({ name: "Prompt Game", engine: "godot", mode: "design", nonInteractive: true }, cwd);
|
||||
const promptPath = path.join(projectRoot, ".codex", "prompts", "market-analyst.md");
|
||||
writeFileSync(promptPath, `${readFileSync(promptPath, "utf8")}\nUNIQUE_PROJECT_PROMPT_SENTINEL\n`);
|
||||
|
||||
const run = prepareRun("market-analyst", { project: projectRoot, task: "Assess competitors", printPrompt: true }, cwd);
|
||||
|
||||
expect(run.prompt).toContain("# Project Role Prompt: .codex/prompts/market-analyst.md");
|
||||
expect(run.prompt).toContain("UNIQUE_PROJECT_PROMPT_SENTINEL");
|
||||
expect(run.contextFiles).toContain(".codex/prompts/market-analyst.md");
|
||||
});
|
||||
|
||||
test("missing generated project role prompt fails clearly", () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-runner-"));
|
||||
const { projectRoot } = initProject({ name: "Missing Prompt Game", engine: "godot", mode: "design", nonInteractive: true }, cwd);
|
||||
rmSync(path.join(projectRoot, ".codex", "prompts", "market-analyst.md"));
|
||||
|
||||
expect(() => prepareRun("market-analyst", { project: projectRoot, task: "Assess competitors", printPrompt: true }, cwd)).toThrow(
|
||||
"Missing generated project role prompt: .codex/prompts/market-analyst.md"
|
||||
);
|
||||
});
|
||||
|
||||
test("fix prompt includes project role prompt", () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-runner-"));
|
||||
const { projectRoot } = initProject({ name: "Fix Prompt Game", engine: "godot", mode: "design", nonInteractive: true }, cwd);
|
||||
|
||||
const run = prepareRun("market-analyst", { project: projectRoot, task: "Assess competitors", dryRun: true, fix: true }, cwd);
|
||||
|
||||
expect(run.fixPrompt).toContain("# Project Role Prompt: .codex/prompts/market-analyst.md");
|
||||
});
|
||||
|
||||
test("role runs inline selected templates", () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-run-template-"));
|
||||
const { projectRoot } = initProject({ name: "Template Run Game", engine: "godot", mode: "design", nonInteractive: true }, cwd);
|
||||
|
||||
const market = prepareRun("market-analyst", { project: projectRoot, task: "Assess positioning", printPrompt: true }, cwd);
|
||||
expect(market.prompt).toContain("## Selected Templates");
|
||||
expect(market.prompt).toContain("Template: market_analysis");
|
||||
expect(market.prompt).toContain("Source: package:templates/market_analysis_template.md");
|
||||
expect(market.prompt).not.toContain("Template: analytics_setup");
|
||||
|
||||
const analytics = prepareRun("data-scientist", { project: projectRoot, task: "Plan evidence loop", printPrompt: true }, cwd);
|
||||
expect(analytics.prompt).toContain("Template: analytics_setup");
|
||||
expect(analytics.prompt).not.toContain("Template: market_analysis");
|
||||
|
||||
const gameplay = prepareRun("gameplay-programmer", { project: projectRoot, task: "Tune movement", printPrompt: true }, cwd);
|
||||
expect(gameplay.prompt).not.toContain("Template: market_analysis");
|
||||
expect(gameplay.prompt).not.toContain("Template: analytics_setup");
|
||||
|
||||
const ui = prepareRun("ui-ux-designer", { project: projectRoot, task: "Review menus", printPrompt: true }, cwd);
|
||||
expect(ui.prompt).toContain("Template: ui_ux_review");
|
||||
expect(ui.prompt).not.toContain("Template: ship_check");
|
||||
|
||||
const release = prepareRun("release-manager", { project: projectRoot, task: "Assess readiness", dryRun: true, fix: true }, cwd);
|
||||
expect(release.prompt).toContain("Template: ship_check");
|
||||
expect(release.fixPrompt).toContain("Template: ship_check");
|
||||
expect(release.prompt).not.toContain("Template: ui_ux_review");
|
||||
});
|
||||
|
||||
test("broad context discovers bounded existing project files", () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-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");
|
||||
|
||||
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).not.toContain("Broad context explicitly allowed by CLI flag.");
|
||||
expect(broad.contextFiles.filter((file) => file.startsWith(".codex/prompts/"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
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");
|
||||
rmSync(gdd);
|
||||
mkdirSync(gdd);
|
||||
const overview = path.join(projectRoot, "resources", "market-research", "market-overview.md");
|
||||
const outside = path.join(cwd, "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");
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
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(
|
||||
"--include-artifact cannot contain control characters"
|
||||
);
|
||||
});
|
||||
|
||||
test("review passes execute Codex with a read-only sandbox", async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-review-"));
|
||||
const { projectRoot } = initProject({ name: "Review Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
|
||||
@@ -48,5 +155,7 @@ console.log(JSON.stringify({ blockers: [], warnings: [], summary: "ok", needsFix
|
||||
expect(invocations).toHaveLength(2);
|
||||
expect(invocations[0].args).toEqual(expect.arrayContaining(["--sandbox", "workspace-write"]));
|
||||
expect(invocations[1].args).toEqual(expect.arrayContaining(["--sandbox", "read-only"]));
|
||||
expect(invocations[1].input).toContain("# Project Role Prompt: .codex/prompts/qa-playtester.md");
|
||||
expect(invocations[1].input).toContain("Template: playtest_report");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { rmSync, writeFileSync } from "node:fs";
|
||||
import { readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { projectRolePromptSourceInput } from "../src/agents.js";
|
||||
import { packageAssetPath } from "../src/paths.js";
|
||||
import { freezeProject, initProject } from "../src/projects.js";
|
||||
import { workflowSourceInput } from "../src/projects.js";
|
||||
import { runValidation, validateProject } from "../src/validation.js";
|
||||
import { hashGeneratedBody, stableHash, stripGeneratedMetadata } from "../src/generated-surfaces.js";
|
||||
import { loadEngineConfigs } from "../src/engines.js";
|
||||
import { rolePackages } from "../src/roles.js";
|
||||
|
||||
describe("validation", () => {
|
||||
test("fresh initialized projects validate and failures are explicit", () => {
|
||||
@@ -72,6 +78,91 @@ describe("validation", () => {
|
||||
expect(validateProject(projectRoot).filter((c) => c.status === "fail")).toEqual([]);
|
||||
});
|
||||
|
||||
test("generated surface hashes are stable for object key order", () => {
|
||||
expect(stableHash({ b: 2, a: { d: 4, c: 3 } })).toBe(stableHash({ a: { c: 3, d: 4 }, b: 2 }));
|
||||
});
|
||||
|
||||
test("generated surface validation detects freshness, body tampering, and legacy metadata", () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-"));
|
||||
const { projectRoot } = initProject({ name: "Freshness Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
|
||||
|
||||
const rolePrompt = path.join(projectRoot, ".codex", "prompts", "market-analyst.md");
|
||||
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 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 metadataBodyPrompt = path.join(metadataBodyProject, ".codex", "prompts", "market-analyst.md");
|
||||
writeFileSync(metadataBodyPrompt, `${readFileSync(metadataBodyPrompt, "utf8")}\n<!-- source-input-sha256: deadbeef -->\n`);
|
||||
expect(validateProject(metadataBodyProject).filter((c) => c.status === "fail").map((c) => c.id)).toContain("codex.role.market-analyst.prompt.body");
|
||||
|
||||
const { projectRoot: rendererProject } = initProject({ name: "Renderer Drift Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
|
||||
const 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 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 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 legacyPrompt = path.join(legacyProject, ".codex", "prompts", "market-analyst.md");
|
||||
writeFileSync(legacyPrompt, readFileSync(legacyPrompt, "utf8").replace(/^<!-- .* -->\n/gm, ""));
|
||||
const legacyChecks = validateProject(legacyProject);
|
||||
expect(legacyChecks).toContainEqual(expect.objectContaining({ id: "codex.role.market-analyst.prompt.freshness", status: "skip" }));
|
||||
expect(legacyChecks.filter((c) => c.status === "fail").map((c) => c.id)).not.toContain("codex.role.market-analyst.prompt.freshness");
|
||||
});
|
||||
|
||||
test("generated surface validation fails malformed metadata without treating it as legacy", () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-val-"));
|
||||
|
||||
const { projectRoot: malformedProject } = initProject({ name: "Malformed Surface Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
|
||||
const malformedPrompt = path.join(malformedProject, ".codex", "prompts", "market-analyst.md");
|
||||
writeFileSync(malformedPrompt, readFileSync(malformedPrompt, "utf8").replace(/^<!-- generated-by: open-gamestudio surface=.* -->\n/m, "<!-- generated-by: open-gamestudio -->\n"));
|
||||
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 partialWorkflow = path.join(partialProject, ".codex", "workflows", "ui-ux-review.md");
|
||||
writeFileSync(partialWorkflow, readFileSync(partialWorkflow, "utf8").replace(/^<!-- rendered-body-sha256: .* -->\n/m, ""));
|
||||
const partialFailures = validateProject(partialProject).filter((c) => c.status === "fail").map((c) => c.id);
|
||||
expect(partialFailures).toContain("codex.workflow.ui-ux-review.body");
|
||||
|
||||
const { projectRoot: legacyProject } = initProject({ name: "Commentless Surface Game", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
|
||||
const 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" }));
|
||||
});
|
||||
|
||||
test("generated surface source input covers rendered engine and role display fields", () => {
|
||||
const engines = loadEngineConfigs(packageAssetPath("engine_configs"));
|
||||
const config = initProject({ name: "Hash Coverage Game", engine: "godot", mode: "prototype", nonInteractive: true }, mkdtempSync(path.join(tmpdir(), "ogs-val-"))).config;
|
||||
const baseRoleHash = stableHash(projectRolePromptSourceInput("market-analyst", config, engines));
|
||||
const renamedEngines = { ...engines, godot: { ...engines.godot, display_name: "Renamed Godot" } };
|
||||
expect(stableHash(projectRolePromptSourceInput("market-analyst", config, renamedEngines))).not.toBe(baseRoleHash);
|
||||
|
||||
const baseWorkflowHash = stableHash(workflowSourceInput("ui-ux-review"));
|
||||
const originalName = rolePackages["ui-ux-designer"].displayName;
|
||||
rolePackages["ui-ux-designer"].displayName = "Renamed UI UX Designer";
|
||||
try {
|
||||
expect(stableHash(workflowSourceInput("ui-ux-review"))).not.toBe(baseWorkflowHash);
|
||||
} finally {
|
||||
rolePackages["ui-ux-designer"].displayName = originalName;
|
||||
}
|
||||
});
|
||||
|
||||
test("repo validation reports Codex readiness hard failure when unavailable", async () => {
|
||||
const old = process.env.CODEX_BIN;
|
||||
process.env.CODEX_BIN = "/missing/codex";
|
||||
|
||||
Reference in New Issue
Block a user