diff --git a/docs/truthmark/engineering/contracts/cli-and-validation.md b/docs/truthmark/engineering/contracts/cli-and-validation.md index 8827855..0db18c0 100644 --- a/docs/truthmark/engineering/contracts/cli-and-validation.md +++ b/docs/truthmark/engineering/contracts/cli-and-validation.md @@ -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 --project ` 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 --role --task --scope ` 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 --role --task --scope ` 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 ` prints approval history, including revoked and expired records as visible non-authorizing records. - `opengamestudio approval revoke --project --approval-id ` sets `revokedAt` on the matching record and preserves approval history. - `opengamestudio run --project --dry-run --approval-scope ` prints approval mismatch diagnostics for guided and strict studio modes without writing prompt cache or run metadata. diff --git a/src/cli.ts b/src/cli.ts index 150bd95..06c2db3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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); diff --git a/src/runner.ts b/src/runner.ts index 5b32bff..87999b2 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -335,10 +335,20 @@ function prepareCustomRun(role: ReturnType 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 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 }; } diff --git a/tests/cli-prompt-surface.test.ts b/tests/cli-prompt-surface.test.ts index 2511ec7..b1a7247 100644 --- a/tests/cli-prompt-surface.test.ts +++ b/tests/cli-prompt-surface.test.ts @@ -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"); diff --git a/tests/customization.test.ts b/tests/customization.test.ts index 728a808..94ad17f 100644 --- a/tests/customization.test.ts +++ b/tests/customization.test.ts @@ -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);