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:
MerlinH
2026-06-26 00:31:03 +10:00
parent 01d8119fbf
commit bea667adff
5 changed files with 90 additions and 6 deletions
@@ -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
View File
@@ -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
View File
@@ -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 };
}
+29
View File
@@ -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");
+35 -2
View File
@@ -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);