mirror of
https://github.com/merlinhu1/codex-game-studio.git
synced 2026-08-25 07:54:34 +02:00
fix: allow approvals for custom implement roles
Allow custom-* role ids in approval grants, compute approval diagnostics for custom role runs, and pass matching approvals into strict/guided eligibility checks. Add regression coverage for custom implement roles using recorded approvals and for CLI approval grants with custom role ids.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
status: active
|
||||
doc_type: contract
|
||||
truth_kind: engineering-contract
|
||||
last_reviewed: 2026-06-14
|
||||
last_reviewed: 2026-06-17
|
||||
source_of_truth:
|
||||
- ../../routes/areas/repository.md
|
||||
---
|
||||
@@ -34,7 +34,7 @@ This bounded leaf truth doc owns the repository CLI command contract, package sc
|
||||
- `opengamestudio workflow <workflow-id> --project <path>` renders either a built-in workflow ID or an extend-only project-local custom workflow ID/alias.
|
||||
- Render-only workflow shortcut commands include market, analytics, design-spec, feel-review, art-direction, ui-review, milestone, handoff, review, ship-check, start, onboard, brainstorm, prototype, architecture-decision, architecture-review, create-epics, create-stories, sprint-plan, sprint-status, story-readiness, story-done, qa-plan, regression-suite, security-audit, perf-profile, release-checklist, hotfix, and localization-plan.
|
||||
- `opengamestudio init` and `opengamestudio new` accept `--studio-mode fast-prototype|guided-studio|strict-studio`, default to `guided-studio` when omitted, and persist that value as `studioMode` in generated `.codex/studio.json`.
|
||||
- `opengamestudio approval grant --project <path> --role <role> --task <id-or-hash> --scope <glob>` appends a scoped approval record when the role is valid, the scope is safe and non-empty, and the task reference is either an existing task ID assigned to the same role or a 64-character SHA-256 objective hash.
|
||||
- `opengamestudio approval grant --project <path> --role <role> --task <id-or-hash> --scope <glob>` appends a scoped approval record when the role is a built-in studio role or syntactically valid `custom-*` role ID, the scope is safe and non-empty, and the task reference is either an existing task ID assigned to the same role or a 64-character SHA-256 objective hash.
|
||||
- `opengamestudio approval list --project <path>` prints approval history, including revoked and expired records as visible non-authorizing records.
|
||||
- `opengamestudio approval revoke --project <path> --approval-id <id>` sets `revokedAt` on the matching record and preserves approval history.
|
||||
- `opengamestudio run <role> --project <path> --dry-run --approval-scope <glob>` prints approval mismatch diagnostics for guided and strict studio modes without writing prompt cache or run metadata.
|
||||
|
||||
+6
-1
@@ -34,6 +34,7 @@ function collectScope(value: string, previous: string[] = []): string[] {
|
||||
program.name("opengamestudio").description("Codex Game Studio: a Codex-native game-development workflow layer").version("0.1.0");
|
||||
|
||||
const sha256Pattern = /^[a-f0-9]{64}$/i;
|
||||
const customRoleIdPattern = /^custom-[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
function localApprovedBy(): string {
|
||||
try {
|
||||
@@ -65,6 +66,10 @@ function readApprovalStudioMode(value: string): StudioMode {
|
||||
throw new Error(`Unsupported studio mode: ${value}`);
|
||||
}
|
||||
|
||||
function isApprovalRoleId(value: string): boolean {
|
||||
return isStudioRoleId(value) || customRoleIdPattern.test(value);
|
||||
}
|
||||
|
||||
function addInitCommand(name: "init" | "new"): void {
|
||||
program
|
||||
.command(name)
|
||||
@@ -150,7 +155,7 @@ approval
|
||||
.option("--allow-broad-scope", "acknowledge broad approval scope such as **/*")
|
||||
.action((opts) => {
|
||||
const projectRoot = resolveTaskProject(opts.project);
|
||||
if (!isStudioRoleId(opts.role)) throw new Error(unknownStudioRoleMessage(opts.role));
|
||||
if (!isApprovalRoleId(opts.role)) throw new Error(unknownStudioRoleMessage(opts.role));
|
||||
const scopes = normalizeApprovalScope(opts.scope, { projectRoot });
|
||||
if (scopes.length === 0) throw new Error("approval grant requires at least one --scope");
|
||||
const broadScope = scopes.find(isBroadApprovalScope);
|
||||
|
||||
+18
-1
@@ -335,10 +335,20 @@ function prepareCustomRun(role: ReturnType<typeof findCustomRole> extends infer
|
||||
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 phase = customPhaseForRun(role);
|
||||
const approvalScopes = normalizeApprovalScope(options.approvalScope ?? [], { projectRoot });
|
||||
const approvalDiagnosticResult = explainApprovalMismatch(readApprovalStore(projectRoot), {
|
||||
role: role.id,
|
||||
objective: task,
|
||||
approvedGlobs: approvalScopes,
|
||||
approvedFiles: artifactDisplays.length ? artifactDisplays : undefined,
|
||||
projectStage: studio.mode,
|
||||
studioMode: studio.studioMode
|
||||
});
|
||||
const eligibility = evaluateStudioRunEligibility({
|
||||
projectStage: studio.mode,
|
||||
studioMode: studio.studioMode,
|
||||
phase,
|
||||
hasMatchingApproval: approvalDiagnosticResult.matched,
|
||||
approvedByUser: options.approvedByUser,
|
||||
constrainedSandbox: options.constrainedSandbox
|
||||
});
|
||||
@@ -379,10 +389,17 @@ function prepareCustomRun(role: ReturnType<typeof findCustomRole> extends infer
|
||||
}
|
||||
const codexBin = options.codexBin ?? resolveCodexCommand();
|
||||
const codexCommand = codexExecInvocation(projectRoot, codexBin, eligibility.codexSandbox);
|
||||
const approvalDiagnostic =
|
||||
options.dryRun && studio.studioMode !== "fast-prototype"
|
||||
? formatApprovalDiagnostic(
|
||||
approvalDiagnosticResult,
|
||||
{ projectStage: studio.mode, studioMode: studio.studioMode, approvalScopes }
|
||||
)
|
||||
: "";
|
||||
const output = options.printPrompt
|
||||
? prompt
|
||||
: options.dryRun
|
||||
? `Prompt cache (not written): ${promptPath}\nMetadata (not written): ${metadataPath}\n${formatEligibility(eligibility)}\nContext files:\n${contextFilesForRun.map((f) => `- ${f}`).join("\n")}\nCodex command: ${codexCommand.display}`
|
||||
? `Prompt cache (not written): ${promptPath}\nMetadata (not written): ${metadataPath}\n${formatEligibility(eligibility)}\nContext files:\n${contextFilesForRun.map((f) => `- ${f}`).join("\n")}\nCodex command: ${codexCommand.display}${approvalDiagnostic ? `\n\n${approvalDiagnostic}` : ""}`
|
||||
: `Prompt cache written: ${promptPath}\n${formatEligibility(eligibility)}\nExecuting Codex: ${codexCommand.display}`;
|
||||
return { prompt, promptPath, metadataPath, projectRoot, role: roleInput, task, contextFiles: contextFilesForRun, verification: options.verifyCommand, codexCommand, output, maxFixPasses, eligibility };
|
||||
}
|
||||
|
||||
@@ -282,6 +282,35 @@ describe("built CLI prompt surface", () => {
|
||||
expect(list).toContain("non-authorizing");
|
||||
});
|
||||
|
||||
test("approval grant accepts custom role ids for precomputed objective hashes", () => {
|
||||
const { cwd, projectRoot } = initCliProject("ogs-cli-custom-approval-", "Custom Approval Game");
|
||||
|
||||
const grant = runCli(
|
||||
[
|
||||
"approval",
|
||||
"grant",
|
||||
"--project",
|
||||
projectRoot,
|
||||
"--role",
|
||||
"custom-boss-designer",
|
||||
"--task",
|
||||
hash64,
|
||||
"--scope",
|
||||
"source/**/*.gd",
|
||||
"--approved-by",
|
||||
"lead"
|
||||
],
|
||||
cwd
|
||||
);
|
||||
|
||||
expect(grant).toContain("approval-001");
|
||||
expect(grant).toContain("role: custom-boss-designer");
|
||||
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"] });
|
||||
});
|
||||
|
||||
test("run dry-run shows approval override advisory and sandbox provenance", () => {
|
||||
const { cwd, projectRoot } = initCliProject("ogs-cli-policy-", "Policy Game");
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { appendApprovalRecord, canonicalObjectiveSha256 } from "../src/approvals.js";
|
||||
import { customizationConfigPath, readProjectCustomization } from "../src/customization.js";
|
||||
import { initProject, statusProject } from "../src/projects.js";
|
||||
import { prepareRun } from "../src/runner.js";
|
||||
@@ -11,7 +12,7 @@ import { formatTemplateShow, listTemplates } from "../src/templates.js";
|
||||
import { validateProject } from "../src/validation.js";
|
||||
import { renderWorkflowPrompt } from "../src/workflows.js";
|
||||
|
||||
function writeValidCustomPack(projectRoot: string): void {
|
||||
function writeValidCustomPack(projectRoot: string, phase: "plan" | "implement" = "plan"): void {
|
||||
mkdirSync(path.join(projectRoot, ".codex", "custom", "roles"), { recursive: true });
|
||||
mkdirSync(path.join(projectRoot, ".codex", "workflows"), { recursive: true });
|
||||
mkdirSync(path.join(projectRoot, "documentation", "templates"), { recursive: true });
|
||||
@@ -30,7 +31,7 @@ function writeValidCustomPack(projectRoot: string): void {
|
||||
displayName: "Boss Designer",
|
||||
promptFile: ".codex/custom/roles/boss-designer.md",
|
||||
contextStrategy: "focused",
|
||||
phase: "plan",
|
||||
phase,
|
||||
expectedOutputs: ["Boss encounter brief", "Readability risks", "Playtest checks"],
|
||||
reviewChecklist: ["Boss phases are readable", "Counterplay is explicit", "Verification is concrete"]
|
||||
}
|
||||
@@ -101,6 +102,38 @@ describe("project-local customization packs", () => {
|
||||
expect(run.output).toContain("Template: custom-boss-brief");
|
||||
});
|
||||
|
||||
test("custom implement roles can run under strict studio with a matching approval", () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-custom-approval-"));
|
||||
const { projectRoot } = initProject({ name: "Custom Approval", engine: "godot", mode: "development", studioMode: "strict-studio", nonInteractive: true }, cwd);
|
||||
writeValidCustomPack(projectRoot, "implement");
|
||||
const task = "Implement a boss encounter controller";
|
||||
const approvedGlobs = ["source/**/*.gd"];
|
||||
const objectiveSha256 = canonicalObjectiveSha256({
|
||||
role: "custom-boss-designer",
|
||||
objective: task,
|
||||
approvedGlobs,
|
||||
projectStage: "development",
|
||||
studioMode: "strict-studio"
|
||||
});
|
||||
appendApprovalRecord(projectRoot, {
|
||||
role: "custom-boss-designer",
|
||||
objectiveSha256,
|
||||
objective: task,
|
||||
projectStage: "development",
|
||||
studioMode: "strict-studio",
|
||||
approvedGlobs,
|
||||
approvedBy: "lead"
|
||||
});
|
||||
|
||||
const run = prepareRun("custom-boss-designer", { project: projectRoot, task, dryRun: true, approvalScope: approvedGlobs }, cwd);
|
||||
|
||||
expect(run.output).toContain("Eligibility: allowed");
|
||||
expect(run.output).toContain("Write policy: approved-write");
|
||||
expect(run.output).toContain("Matching approval: true");
|
||||
expect(run.output).toContain("Approval diagnostic:");
|
||||
expect(run.output).toContain("approval-001: authorizing");
|
||||
});
|
||||
|
||||
test("customization validation rejects a custom workflow with a missing file", () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-custom-missing-workflow-"));
|
||||
const { projectRoot } = initProject({ name: "Missing Workflow", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
|
||||
|
||||
Reference in New Issue
Block a user