diff --git a/bin/cewp.js b/bin/cewp.js index a6b91b2..c5c2af2 100644 --- a/bin/cewp.js +++ b/bin/cewp.js @@ -34,6 +34,7 @@ const { init } = require("../src/skills/install"); const { list, doctor } = require("../src/skills/status"); const { runSupervise } = require("../src/supervise/cli"); const { runWorkflow } = require("../src/workflow/cli"); +const { runIntegration } = require("../src/integration/cli"); const { printHuman: printSupervisedDemo, runSupervisedDemo } = require("../src/demo/supervised"); function runDemo(options) { @@ -209,12 +210,17 @@ async function main() { return; } + if (args.command === "integration") { + runIntegration(args); + return; + } + if (args.command === "demo") { runDemo(args); return; } - if (!["init", "list", "doctor", "policy", "run", "supervise", "workflow", "demo"].includes(args.command)) { + if (!["init", "list", "doctor", "policy", "run", "supervise", "workflow", "integration", "demo"].includes(args.command)) { throw new Error(`Unsupported command: ${args.command}`); } } catch (error) { diff --git a/docs/codex-capability-matrix.md b/docs/codex-capability-matrix.md index c6ec9f3..3001d03 100644 --- a/docs/codex-capability-matrix.md +++ b/docs/codex-capability-matrix.md @@ -47,6 +47,8 @@ Schema presence does not prove that a plugin can attach to the desktop app's exi | Desktop notifications | host-specific | The host owns documented notification behavior and settings. CEWP has no arbitrary notification category. | | Hook `statusMessage` | supported | Official hook configuration exposes it as transient handler status. | | Hook `systemMessage` | supported | Official hook output exposes it as a UI or event-stream warning. | +| `SubagentStart`/`SubagentStop` evidence | supported, opt-in | The plugin records only documented parent session/turn, agent id/type, permission/model context, and bounded stop summary after separate CEWP approval and host `/hooks` trust. The documented input exposes no subagent thread id, so CEWP preserves it as `unknown`. | +| Hook trust and version drift | supported | `npm run test:integration-hook-evidence` binds the exact bundle, Codex version, CEWP runtime, hook contract, and workflow revision. Drift or malformed input emits a warning, appends no trusted evidence, and leaves Core gates unchanged. | | `PreToolUse` deny output | supported | The deterministic fixture emits the documented `permissionDecision: deny` shape and is covered by `npm run test:hook-output`. | | `PreToolUse` as complete enforcement | unavailable | Official docs exclude or limit richer shell and non-MCP paths. A real CLI 0.137.0 Windows probe executed the requested PowerShell command despite the Bash deny hook. Core policy remains authoritative. | | Hook-based instant turn cancellation | unknown | Stop semantics do not establish instantaneous cancellation of an in-flight model or external process. | @@ -115,6 +117,7 @@ npm run probe:codex-app-server npm run test:plugin-lifecycle npm run test:hook-output npm run test:integration-capabilities +npm run test:integration-hook-evidence ``` The nested model probe is intentionally excluded from automated tests because it consumes account usage. Raw account values, credentials, thread ids, and machine-specific paths are not part of this document. diff --git a/docs/known-limitations.md b/docs/known-limitations.md index d17d910..913d3a5 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -10,7 +10,7 @@ CEWP is beta software. These limits are product boundaries, not hidden roadmap p - ChatGPT subscription credit impact and host-internal retries or compaction remain `unknown` without a supported machine-readable contract. - Numeric usage estimates stay unavailable until enough comparable local runs exist. When available, they are ranges with confidence and sample basis, never point promises. - File-level test-authoring enforcement recognizes common test directories and filename conventions. It cannot prove whether production code contains test-like logic. -- The plugin contributes skills only. It does not provide MCP tools, hooks, an Apps SDK card, or an App Server client. +- The plugin contributes skills and an optional review-required `SubagentStart`/`SubagentStop` evidence hook. The hook cannot expose a subagent thread id, does not read transcripts, and is never a Core enforcement boundary. Local MCP, an Apps SDK card, and an App Server client are not yet shipped. - Experimental OpenCode execution remains optional and outside the supervised golden path. Binary/version availability does not prove authentication or model readiness. - Manual is a non-executing handoff adapter. Claude, Gemini, Hermes, and other providers are not implemented. - Supervised worktree cleanup automation is not shipped; rollback is available for owned unverified work, and terminal evidence is retained for deliberate inspection/removal. diff --git a/docs/skill-plugin-compatibility.md b/docs/skill-plugin-compatibility.md index 1a06e95..a6f9ac6 100644 --- a/docs/skill-plugin-compatibility.md +++ b/docs/skill-plugin-compatibility.md @@ -1,6 +1,6 @@ # Skill And Plugin Compatibility -Observed: 2026-07-16 +Observed: 2026-07-22 CEWP's ten bundled skills use the current Codex skill shape: a required `SKILL.md` with `name` and `description`, plus optional `scripts/`, `references/`, `assets/`, and `agents/`. `agents/openai.yaml` is accepted as optional UI, invocation-policy, and dependency metadata. CEWP no longer treats these official components as forbidden. @@ -23,7 +23,7 @@ The Phase 9 plugin follows the official boundary: - `.codex-plugin/plugin.json` is the required manifest and the only file under `.codex-plugin/`. - `skills/`, `hooks/`, `.mcp.json`, `.app.json`, and `assets/` live at the plugin root and use `./`-prefixed contained paths. -- Installing or enabling a plugin does not trust its bundled hooks. CEWP hooks remain optional until the user reviews and trusts the current definition. +- Installing or enabling a plugin does not trust its bundled hooks. CEWP declares one contained subagent-evidence bundle, requires a run-bound operator approval, and still directs the user to `/hooks` to review and trust the exact current host definition. Bundle, Codex, CEWP runtime, hook-contract, or workflow-revision drift disables trusted evidence and falls back to Core plus conversation output. - A repo marketplace lives at `.agents/plugins/marketplace.json`; npm remains the source of the CEWP Core CLI/runtime. - MCP and Apps SDK components are optional projections. The plugin skeleton and golden path cannot depend on them until their versioned capability tests pass. diff --git a/docs/supervised-workflow.md b/docs/supervised-workflow.md index 10e57ad..b68cc6b 100644 --- a/docs/supervised-workflow.md +++ b/docs/supervised-workflow.md @@ -56,6 +56,26 @@ Changing the goal, scope, checks, or stopping conditions creates a new plan revi ## 3. Execute And Verify +An optional Codex-specific sidecar can assign an explicit task class without changing the +provider-neutral workflow or run schemas: + +```bash +cewp supervise effort \ + --operation implementation \ + --task-class demanding-implementation \ + --model \ + --effort high \ + --yes +``` + +Supported operations are `implementation`, `repair`, and `reviewer`. Supported task classes are +`fast-exploration`, `demanding-implementation`, and `high-effort-independent-review`. A task class +never selects a model or reasoning effort automatically. Every initial selection or change requires +`--yes`, creates a revision with an operator-approval digest, and fails closed when the sidecar's +approved selection digest no longer matches. Model and effort remain `unknown` when omitted. Explicit selections become known +effective evidence only after supported structured turn-completion usage is received; an early CLI, +model, or host rejection leaves them unknown. + Managed execution is an advanced local action: ```bash diff --git a/package.json b/package.json index 811b8db..7c6d52b 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ }, "scripts": { "test": "npm run test:contracts && npm run smoke", - "test:contracts": "npm run test:init-install && npm run test:adapter-profile && npm run test:doctor-json && npm run test:operator-json && npm run test:hook-output && npm run test:skill-format && npm run test:ownership-gates && npm run test:fixtures && npm run test:plugin-package && npm run test:supervised-intake && npm run test:supervised-proposal && npm run test:supervised-controls && npm run test:supervised-execution && npm run test:supervised-review && npm run test:supervised-failure && npm run test:supervised-linear-resume && npm run test:supervised-demo && npm run test:workflow-definition && npm run test:workflow-compiler && npm run test:workflow-proposal && npm run test:workflow-scheduler && npm run test:workflow-worker-matrix && npm run test:workflow-result && npm run test:workflow-failure-result && npm run test:workflow-state-machine && npm run test:workflow-interventions && npm run test:workflow-failure-matrix && npm run test:workflow-budget && npm run test:workflow-progress && npm run test:workflow-review && npm run test:workflow-checkpoint-review && npm run test:workflow-lifecycle && npm run test:workflow-revision && npm run test:workflow-migration && npm run test:workflow-templates && npm run test:workflow-docs && npm run test:workflow-release && npm run test:integration-capabilities && npm run test:integration-binding && npm run test:integration-observation && npm run test:native-goal-events", + "test:contracts": "npm run test:init-install && npm run test:adapter-profile && npm run test:doctor-json && npm run test:operator-json && npm run test:hook-output && npm run test:skill-format && npm run test:ownership-gates && npm run test:fixtures && npm run test:plugin-package && npm run test:supervised-intake && npm run test:supervised-proposal && npm run test:supervised-controls && npm run test:supervised-execution && npm run test:supervised-review && npm run test:supervised-failure && npm run test:supervised-linear-resume && npm run test:supervised-demo && npm run test:workflow-definition && npm run test:workflow-compiler && npm run test:workflow-proposal && npm run test:workflow-scheduler && npm run test:workflow-worker-matrix && npm run test:workflow-result && npm run test:workflow-failure-result && npm run test:workflow-state-machine && npm run test:workflow-interventions && npm run test:workflow-failure-matrix && npm run test:workflow-budget && npm run test:workflow-progress && npm run test:workflow-review && npm run test:workflow-checkpoint-review && npm run test:workflow-lifecycle && npm run test:workflow-revision && npm run test:workflow-migration && npm run test:workflow-templates && npm run test:workflow-docs && npm run test:workflow-release && npm run test:integration-capabilities && npm run test:integration-binding && npm run test:integration-effort-policy && npm run test:integration-hook-evidence && npm run test:integration-observation && npm run test:native-goal-events", "test:init-install": "node tests/contracts/init-install.js", "test:adapter-profile": "node tests/contracts/adapter-profile.js", "test:doctor-json": "node tests/contracts/doctor-json.js", @@ -90,6 +90,8 @@ "test:workflow-release": "node tests/contracts/workflow-release.js", "test:integration-capabilities": "node tests/contracts/integration-capabilities.js", "test:integration-binding": "node tests/contracts/integration-binding.js", + "test:integration-effort-policy": "node tests/contracts/integration-effort-policy.js", + "test:integration-hook-evidence": "node tests/contracts/integration-hook-evidence.js", "test:integration-observation": "node tests/contracts/integration-observation.js", "test:native-goal-events": "node tests/contracts/native-goal-events.js", "demo:supervised": "node src/demo/supervised.js", @@ -98,7 +100,7 @@ "probe:codex-app-server": "node tests/capabilities/codex-app-server.js", "smoke": "node tests/harness/run-smoke.js", "check:cli": "node ./bin/cewp.js --help", - "check:syntax": "node --check ./bin/cewp.js && node --check ./src/demo/fake-codex.js && node --check ./src/demo/supervised.js && node --check ./src/integration/capabilities.js && node --check ./src/integration/binding.js && node --check ./src/integration/observation.js && node --check ./src/integration/native-goal.js && node --check ./src/skills/install.js && node --check ./src/skills/format.js && node --check ./src/skills/doctor-report.js && node --check ./src/run/ownership.js && node --check ./src/run/control-gates.js && node --check ./src/supervise/budget.js && node --check ./src/supervise/cli.js && node --check ./src/supervise/commands.js && node --check ./src/supervise/controls.js && node --check ./src/supervise/execution.js && node --check ./src/supervise/profiles.js && node --check ./src/supervise/receipt.js && node --check ./src/supervise/review.js && node --check ./src/supervise/state.js && node --check ./src/supervise/test-authoring.js && node --check ./src/supervise/verification.js && node --check ./src/workflow/cli.js && node --check ./src/workflow/definition.js && node --check ./src/workflow/graph.js && node --check ./src/workflow/result.js && node --check ./src/workflow/scheduler.js && node --check ./src/workflow/source.js && node --check ./src/workflow/state.js && node --check ./src/workflow/transitions.js && node --check ./tests/contracts/init-install.js && node --check ./tests/contracts/adapter-profile.js && node --check ./tests/contracts/doctor-json.js && node --check ./tests/contracts/operator-json.js && node --check ./tests/contracts/hook-output.js && node --check ./tests/contracts/skill-format.js && node --check ./tests/contracts/ownership-gates.js && node --check ./tests/contracts/deterministic-fixtures.js && node --check ./tests/contracts/plugin-package.js && node --check ./tests/contracts/supervised-intake.js && node --check ./tests/contracts/supervised-proposal.js && node --check ./tests/contracts/supervised-controls.js && node --check ./tests/contracts/supervised-execution.js && node --check ./tests/contracts/supervised-review.js && node --check ./tests/contracts/supervised-failure.js && node --check ./tests/contracts/supervised-linear-resume.js && node --check ./tests/contracts/supervised-demo.js && node --check ./tests/contracts/integration-capabilities.js && node --check ./tests/contracts/integration-binding.js && node --check ./tests/contracts/integration-observation.js && node --check ./tests/contracts/native-goal-events.js && node --check ./tests/contracts/workflow-definition.js && node --check ./tests/contracts/workflow-proposal.js && node --check ./tests/contracts/workflow-scheduler.js && node --check ./tests/contracts/workflow-result.js && node --check ./tests/contracts/workflow-state-machine.js && node --check ./tests/contracts/workflow-interventions.js && node --check ./tests/capabilities/clean-install.js && node --check ./tests/capabilities/plugin-lifecycle.js && node --check ./tests/capabilities/codex-app-server.js && node --check ./tests/capabilities/fixtures/deny-shell-hook.js", + "check:syntax": "node --check ./bin/cewp.js && node --check ./src/demo/fake-codex.js && node --check ./src/demo/supervised.js && node --check ./src/integration/capabilities.js && node --check ./src/integration/binding.js && node --check ./src/integration/cli.js && node --check ./src/integration/effort-policy.js && node --check ./src/integration/hook-evidence.js && node --check ./src/integration/observation.js && node --check ./src/integration/native-goal.js && node --check ./src/skills/install.js && node --check ./src/skills/format.js && node --check ./src/skills/doctor-report.js && node --check ./src/run/ownership.js && node --check ./src/run/control-gates.js && node --check ./src/supervise/budget.js && node --check ./src/supervise/cli.js && node --check ./src/supervise/commands.js && node --check ./src/supervise/controls.js && node --check ./src/supervise/execution.js && node --check ./src/supervise/profiles.js && node --check ./src/supervise/receipt.js && node --check ./src/supervise/review.js && node --check ./src/supervise/state.js && node --check ./src/supervise/test-authoring.js && node --check ./src/supervise/verification.js && node --check ./src/workflow/cli.js && node --check ./src/workflow/definition.js && node --check ./src/workflow/graph.js && node --check ./src/workflow/result.js && node --check ./src/workflow/scheduler.js && node --check ./src/workflow/source.js && node --check ./src/workflow/state.js && node --check ./src/workflow/transitions.js && node --check ./plugins/cewp/hooks/capture-subagent.js && node --check ./tests/contracts/init-install.js && node --check ./tests/contracts/adapter-profile.js && node --check ./tests/contracts/doctor-json.js && node --check ./tests/contracts/operator-json.js && node --check ./tests/contracts/hook-output.js && node --check ./tests/contracts/skill-format.js && node --check ./tests/contracts/ownership-gates.js && node --check ./tests/contracts/deterministic-fixtures.js && node --check ./tests/contracts/plugin-package.js && node --check ./tests/contracts/supervised-intake.js && node --check ./tests/contracts/supervised-proposal.js && node --check ./tests/contracts/supervised-controls.js && node --check ./tests/contracts/supervised-execution.js && node --check ./tests/contracts/supervised-review.js && node --check ./tests/contracts/supervised-failure.js && node --check ./tests/contracts/supervised-linear-resume.js && node --check ./tests/contracts/supervised-demo.js && node --check ./tests/contracts/integration-capabilities.js && node --check ./tests/contracts/integration-binding.js && node --check ./tests/contracts/integration-effort-policy.js && node --check ./tests/contracts/integration-hook-evidence.js && node --check ./tests/contracts/integration-observation.js && node --check ./tests/contracts/native-goal-events.js && node --check ./tests/contracts/workflow-definition.js && node --check ./tests/contracts/workflow-proposal.js && node --check ./tests/contracts/workflow-scheduler.js && node --check ./tests/contracts/workflow-result.js && node --check ./tests/contracts/workflow-state-machine.js && node --check ./tests/contracts/workflow-interventions.js && node --check ./tests/capabilities/clean-install.js && node --check ./tests/capabilities/plugin-lifecycle.js && node --check ./tests/capabilities/codex-app-server.js && node --check ./tests/capabilities/fixtures/deny-shell-hook.js", "check:workflow-syntax": "node --check ./src/workflow/budget.js && node --check ./src/workflow/compiler.js && node --check ./src/workflow/migration.js && node --check ./src/workflow/progress.js && node --check ./src/workflow/proposal.js && node --check ./src/workflow/review.js && node --check ./src/workflow/revision.js && node --check ./src/workflow/templates.js && node --check ./tests/contracts/workflow-budget.js && node --check ./tests/contracts/workflow-checkpoint-review.js && node --check ./tests/contracts/workflow-compiler.js && node --check ./tests/contracts/workflow-docs.js && node --check ./tests/contracts/workflow-failure-matrix.js && node --check ./tests/contracts/workflow-failure-result.js && node --check ./tests/contracts/workflow-migration.js && node --check ./tests/contracts/workflow-progress.js && node --check ./tests/contracts/workflow-release.js && node --check ./tests/contracts/workflow-review.js && node --check ./tests/contracts/workflow-lifecycle.js && node --check ./tests/contracts/workflow-revision.js && node --check ./tests/contracts/workflow-templates.js && node --check ./tests/contracts/workflow-worker-matrix.js", "check": "npm run check:syntax && npm run check:workflow-syntax && npm test", "pack:dry-run": "npm pack --dry-run" diff --git a/plugins/cewp/.codex-plugin/plugin.json b/plugins/cewp/.codex-plugin/plugin.json index b04e299..e87c827 100644 --- a/plugins/cewp/.codex-plugin/plugin.json +++ b/plugins/cewp/.codex-plugin/plugin.json @@ -16,6 +16,7 @@ "evidence" ], "skills": "./skills/", + "hooks": "./hooks/hooks.json", "interface": { "displayName": "CEWP", "shortDescription": "Long-running goals without blind runs", diff --git a/plugins/cewp/README.md b/plugins/cewp/README.md index 5fba658..5c3e509 100644 --- a/plugins/cewp/README.md +++ b/plugins/cewp/README.md @@ -8,7 +8,19 @@ It ships exactly three entry skills: - `run-supervised-checkpoint`: execute one controlled model operation and follow Core gates. - `resume-supervised-run`: inspect or recover canonical state without silently restarting work. -The plugin does not attach to the ChatGPT desktop app's private thread, automate native goals, inject persistent UI, expose hidden host usage, execute the optional OpenCode adapter, or add another provider. Phase 9 uses one selected pair: `managed` owner with the `codex-exec` backend. +The plugin does not attach to the ChatGPT desktop app's private thread, automate native goals, inject persistent UI, expose hidden host usage, execute the optional OpenCode adapter, or add another provider. The managed path uses one selected pair: `managed` owner with the `codex-exec` backend. + +## Optional Subagent Evidence + +The plugin declares one `SubagentStart`/`SubagentStop` hook bundle. Installation or enablement does not trust it. For a selected workflow run, first inspect the bundle and activate the CEWP-side binding: + +```bash +cewp integration hooks approve --yes --json +``` + +Then open `/hooks` in Codex, review the exact current definition, and decide whether to trust it. `cewp integration hooks status --json` reports bundle, Codex, CEWP runtime, hook-contract, and workflow-revision drift. A changed or malformed source produces a warning and no trusted evidence. + +The hook records bounded parent session/turn references, the documented subagent id/type, and the stop summary in a provider-specific sidecar. The documented hook input does not expose a subagent thread id, so that field remains `unknown`. The handler never reads transcript files, never blocks or continues a subagent, and never opens a policy, verification, review, or finalization gate. Install from the CEWP source marketplace: @@ -17,4 +29,4 @@ codex plugin marketplace add /path/to/Codex-Engineering-Workflow-Pack codex plugin add cewp@cewp-local ``` -The npm package supplies the `cewp` runtime used by these skills. Run `cewp doctor --json` before the first supervised checkpoint. +The npm package supplies the `cewp` runtime used by the skills and optional evidence handler. Run `cewp doctor --json` before the first supervised checkpoint. diff --git a/plugins/cewp/hooks/capture-subagent.js b/plugins/cewp/hooks/capture-subagent.js new file mode 100644 index 0000000..7d0b9ed --- /dev/null +++ b/plugins/cewp/hooks/capture-subagent.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +"use strict"; + +const childProcess = require("node:child_process"); + +process.stdin.setEncoding("utf8"); +let input = ""; +let oversized = false; +process.stdin.on("data", (chunk) => { + input += chunk; + if (Buffer.byteLength(input, "utf8") > 64 * 1024) oversized = true; +}); +process.stdin.on("end", () => { + try { + if (oversized) throw new Error("hook input exceeds 65536 bytes"); + JSON.parse(input || "{}"); + const command = process.env.CEWP_HOOK_CLI_COMMAND || (process.platform === "win32" ? "cewp.cmd" : "cewp"); + const prefixArgs = process.env.CEWP_HOOK_CLI_PREFIX_ARGS + ? JSON.parse(process.env.CEWP_HOOK_CLI_PREFIX_ARGS) + : []; + if (!Array.isArray(prefixArgs) || prefixArgs.some((value) => typeof value !== "string")) { + throw new Error("invalid CEWP hook CLI prefix configuration"); + } + const result = childProcess.spawnSync(command, [ + ...prefixArgs, + "integration", "hooks", "ingest", + ], { + cwd: process.cwd(), + input, + encoding: "utf8", + windowsHide: true, + timeout: 15000, + shell: process.platform === "win32" && !process.env.CEWP_HOOK_CLI_COMMAND, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + const reason = String(result.stderr || "CEWP hook ingestion failed") + .replace(/\s+/g, " ") + .trim() + .slice(0, 1000); + throw new Error(reason); + } + process.stdout.write("{}\n"); + } catch (error) { + process.stdout.write(`${JSON.stringify({ + systemMessage: `CEWP hook evidence unavailable: ${error.message} Core gates remain unchanged.`, + })}\n`); + } +}); diff --git a/plugins/cewp/hooks/hooks.json b/plugins/cewp/hooks/hooks.json new file mode 100644 index 0000000..ef858e0 --- /dev/null +++ b/plugins/cewp/hooks/hooks.json @@ -0,0 +1,31 @@ +{ + "description": "Optional CEWP subagent lifecycle evidence. CEWP Core gates remain authoritative.", + "hooks": { + "SubagentStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node \"$PLUGIN_ROOT/hooks/capture-subagent.js\"", + "command_windows": "node \"%PLUGIN_ROOT%\\hooks\\capture-subagent.js\"", + "statusMessage": "Recording optional CEWP subagent evidence" + } + ] + } + ], + "SubagentStop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node \"$PLUGIN_ROOT/hooks/capture-subagent.js\"", + "command_windows": "node \"%PLUGIN_ROOT%\\hooks\\capture-subagent.js\"", + "statusMessage": "Recording optional CEWP subagent summary" + } + ] + } + ] + } +} diff --git a/plugins/cewp/skills/run-supervised-checkpoint/SKILL.md b/plugins/cewp/skills/run-supervised-checkpoint/SKILL.md index 717e208..4350cc0 100644 --- a/plugins/cewp/skills/run-supervised-checkpoint/SKILL.md +++ b/plugins/cewp/skills/run-supervised-checkpoint/SKILL.md @@ -11,9 +11,9 @@ Use canonical JSON from `cewp supervise`; generated Markdown is a view, not muta 2. Perform no more than one CEWP-controlled model operation per invocation. Local verification commands do not count as model operations, but their approved limits still apply. 3. Follow only the transition matching canonical state: - `proposed`: show the plan and run `cewp supervise approve --yes --json` only after explicit approval. Stop before dispatch. - - `approved/ready`: run `cewp supervise execute --yes --json` only after explicit execution approval. If dispatch reaches `verifying`, run `cewp supervise verify --json`; then stop at the verified, repair, paused, or blocked result. - - `needs-repair/repair-ready`: explain the failure signature and remaining repair allocation. Run `cewp supervise retry --yes --json` only when the user explicitly chooses retry, then run local verification and stop. - - `checkpoint-complete/verified`: offer final review or a safe pause for one manually bounded next checkpoint. For final review, record `cewp supervise continue --json`, run `cewp supervise review --yes --json`, and stop at the reviewer decision. For more work, hand off to the resume workflow without dispatching another model operation. + - `approved/ready`: if the operator explicitly requested a Codex task class, model, or effort, record it first with `cewp supervise effort --operation implementation --task-class [--model ] [--effort ] --yes --json`. Never infer model or effort from the class. Then run `cewp supervise execute --yes --json` only after explicit execution approval. If dispatch reaches `verifying`, run `cewp supervise verify --json`; then stop at the verified, repair, paused, or blocked result. + - `needs-repair/repair-ready`: explain the failure signature and remaining repair allocation. Record any explicitly requested repair-specific Codex selection through `cewp supervise effort ... --operation repair ... --yes --json`; do not reuse or change implementation settings implicitly. Run `cewp supervise retry --yes --json` only when the user explicitly chooses retry, then run local verification and stop. + - `checkpoint-complete/verified`: offer final review or a safe pause for one manually bounded next checkpoint. For final review, record any explicitly requested independent-review Codex selection through `cewp supervise effort ... --operation reviewer ... --yes --json`, record `cewp supervise continue --json`, run `cewp supervise review --yes --json`, and stop at the reviewer decision. For more work, hand off to the resume workflow without dispatching another model operation. - `review-passed`: run `cewp supervise receipt --json` to preview the receipt. Do not finalize in the same step unless the user already gave explicit finalize intent after seeing equivalent receipt facts. - `ready-to-finalize`: run `cewp supervise finalize --yes --json` only after explicit finalization approval. - any `paused-*` or `blocked`: do not dispatch. Present Core recovery actions and hand off to the resume workflow. diff --git a/src/cli/parse.js b/src/cli/parse.js index 15b3c04..e39fae8 100644 --- a/src/cli/parse.js +++ b/src/cli/parse.js @@ -74,6 +74,10 @@ function parseArgs(argv) { workerId: undefined, templateName: undefined, compilerDigest: undefined, + operation: undefined, + taskClass: undefined, + model: undefined, + effort: undefined, }; if (argv[0] === "--help" || argv[0] === "-h") { @@ -93,7 +97,7 @@ function parseArgs(argv) { return args; } - const optionStart = ["run", "supervise", "workflow", "demo"].includes(args.command) ? 2 : 1; + const optionStart = ["run", "supervise", "workflow", "integration", "demo"].includes(args.command) ? 2 : 1; for (let index = optionStart; index < argv.length; index += 1) { const arg = argv[index]; @@ -128,6 +132,16 @@ function parseArgs(argv) { continue; } + if (args.command === "integration" && args.subcommand === "hooks" && index === 2) { + args.action = arg; + continue; + } + + if (args.command === "integration" && args.subcommand === "hooks" && index === 3 && !arg.startsWith("--")) { + args.workflowRunId = arg; + continue; + } + if (args.command === "workflow" && args.subcommand === "validate" && index === 2 && !arg.startsWith("--")) { args.definitionFile = arg; continue; @@ -203,7 +217,7 @@ function parseArgs(argv) { if ( args.command === "supervise" && [ - "approve", "status", "execute", "verify", "retry", "review", "receipt", "finalize", + "approve", "status", "execute", "verify", "retry", "review", "receipt", "finalize", "effort", "revise", "pause", "resume", "add-budget", "rollback", "cancel", "abandon", "block", "continue", "reassign", ].includes(args.subcommand) && index === 2 @@ -213,6 +227,38 @@ function parseArgs(argv) { continue; } + if (args.command === "supervise" && arg === "--operation") { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error("--operation requires an effort-policy operation."); + args.operation = value; + index += 1; + continue; + } + + if (args.command === "supervise" && arg === "--task-class") { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error("--task-class requires a Codex task class."); + args.taskClass = value; + index += 1; + continue; + } + + if (args.command === "supervise" && arg === "--model") { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error("--model requires an explicit Codex model."); + args.model = value; + index += 1; + continue; + } + + if (args.command === "supervise" && arg === "--effort") { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error("--effort requires a supported Codex reasoning effort."); + args.effort = value; + index += 1; + continue; + } + if ( ( args.command === "supervise" @@ -410,7 +456,7 @@ function parseArgs(argv) { continue; } - if (["doctor", "run", "supervise", "workflow", "demo"].includes(args.command) && arg === "--json") { + if (["doctor", "run", "supervise", "workflow", "integration", "demo"].includes(args.command) && arg === "--json") { args.json = true; continue; } @@ -425,6 +471,12 @@ function parseArgs(argv) { continue; } + + if (args.command === "integration" && arg === "--yes") { + args.yes = true; + continue; + } + if (args.command === "supervise" && arg === "--allow-test-authoring") { args.allowTestAuthoring = true; continue; diff --git a/src/cli/usage.js b/src/cli/usage.js index b804c0c..7a7aa4a 100644 --- a/src/cli/usage.js +++ b/src/cli/usage.js @@ -17,6 +17,8 @@ Usage: cewp workflow result --task --result --yes [--json] cewp workflow review --result --yes [--json] cewp workflow finalize --yes [--json] + cewp integration hooks approve --yes [--json] + cewp integration hooks status [--json] cewp workflow revise --proposal [--from ] [--json] cewp workflow apply-revision --proposal --digest --yes [--json] cewp workflow migrate [--digest --yes] [--json] @@ -31,6 +33,7 @@ Usage: cewp supervise plan --proposal [--from ] [--source-kind ] [--json] cewp supervise approve [run-id] [--allow-test-authoring] --yes [--json] cewp supervise status [run-id] [--json] + cewp supervise effort [run-id] --operation --task-class [--model ] [--effort ] --yes [--json] cewp supervise execute [run-id] --yes [--timeout ] [--json] cewp supervise verify [run-id] [--timeout ] [--json] cewp supervise retry [run-id] --yes [--timeout ] [--json] diff --git a/src/integration/binding.js b/src/integration/binding.js index a9027b4..b78b03e 100644 --- a/src/integration/binding.js +++ b/src/integration/binding.js @@ -7,6 +7,12 @@ const { selectManagedBackend, validateCodexCapabilitySnapshot, } = require("./capabilities"); +const { + OWNERSHIP_SCHEMA_VERSION, + findOwnershipConflict, + loadOwnershipRecords, + validateOwnershipRecord, +} = require("../run/ownership"); const { writeJsonAtomic } = require("../workflow/state"); const HOST_BINDING_SCHEMA_VERSION = "host-binding/v1"; @@ -255,6 +261,39 @@ function bindingPath(found) { return path.join(found.runRoot, "integration", "host-binding.json"); } +function bindingOwnershipPath(found) { + return path.join(found.runRoot, "integration", "ownership.json"); +} + +function claimBindingWorktree(found, binding) { + if (!binding.references.worktree) return null; + if (!binding.workflow.taskId || !binding.workflow.checkpointId) { + throw new Error("Host binding worktree ownership requires a task and active checkpoint identity."); + } + const filePath = bindingOwnershipPath(found); + const requested = validateOwnershipRecord({ + schemaVersion: OWNERSHIP_SCHEMA_VERSION, + runId: binding.workflow.runId, + taskId: binding.workflow.taskId, + checkpointId: binding.workflow.checkpointId, + owner: binding.execution.owner, + backend: binding.execution.backend, + status: "active", + createdAt: binding.provenance.recordedAt, + cleanupAuthority: binding.execution.owner === "managed" ? "cewp-core" : "host-owner", + worktree: binding.references.worktree, + }); + const existing = loadOwnershipRecords(found.repoRoot, { excludePath: filePath }); + const conflict = findOwnershipConflict(existing, requested, { repoRoot: found.repoRoot }); + if (conflict) { + throw new Error( + `Host binding execution ownership conflict with ${conflict.owner} run ${conflict.runId} task ${conflict.taskId}.`, + ); + } + writeJsonAtomic(filePath, requested); + return requested; +} + function createHostBinding(found, candidate, options = {}) { const binding = validateHostBinding(candidate, found); if (!options.capabilities) throw new Error("Host binding requires a versioned capability snapshot."); @@ -264,6 +303,7 @@ function createHostBinding(found, candidate, options = {}) { throw new Error(`Host binding already exists for workflow run ${found.run.runId}.`); } fs.mkdirSync(path.dirname(filePath), { recursive: true }); + claimBindingWorktree(found, binding); writeJsonAtomic(filePath, binding); return binding; } diff --git a/src/integration/cli.js b/src/integration/cli.js new file mode 100644 index 0000000..26a10b9 --- /dev/null +++ b/src/integration/cli.js @@ -0,0 +1,66 @@ +"use strict"; + +const fs = require("node:fs"); +const { + approveCodexHookTrust, + inspectCodexHookTrust, + recordSubagentHookEvent, +} = require("./hook-evidence"); + +function outputJson(command, data) { + console.log(JSON.stringify({ + schemaVersion: "operator-json/v1", + command, + generatedAt: new Date().toISOString(), + data, + warnings: [], + }, null, 2)); +} + +function runIntegration(options = {}) { + if (options.subcommand === "hooks" && options.action === "ingest") { + const raw = fs.readFileSync(0, "utf8"); + if (Buffer.byteLength(raw, "utf8") > 64 * 1024) { + throw new Error("Codex hook input exceeds 65536 bytes."); + } + const input = JSON.parse(raw || "{}"); + recordSubagentHookEvent({ input }); + console.log("{}"); + return; + } + + if (options.subcommand === "hooks" && options.action === "approve") { + if (!options.workflowRunId) throw new Error("integration hooks approve requires a workflow run id."); + const result = approveCodexHookTrust({ + repoRoot: process.cwd(), + runId: options.workflowRunId, + yes: options.yes, + }); + if (options.json) outputJson("integration.hooks.approve", result); + else { + console.log("CEWP Codex hook evidence approved"); + console.log(`Run ID: ${result.trust.runId}`); + console.log(`Bundle: ${result.trust.bundleDigest}`); + console.log("Next: open /hooks and review the current definition"); + } + return; + } + if (options.subcommand === "hooks" && options.action === "status") { + if (!options.workflowRunId) throw new Error("integration hooks status requires a workflow run id."); + const result = inspectCodexHookTrust({ + repoRoot: process.cwd(), + runId: options.workflowRunId, + }); + if (options.json) outputJson("integration.hooks.status", result); + else { + console.log("CEWP Codex hook evidence status"); + console.log(`Run ID: ${result.runId}`); + console.log(`Active: ${result.active ? "yes" : "no"}`); + result.warnings.forEach((warning) => console.log(`Warning: ${warning.code}: ${warning.message}`)); + } + return; + } + throw new Error(`Unsupported integration command: ${options.subcommand || "missing"}.`); +} + +module.exports = { runIntegration }; diff --git a/src/integration/effort-policy.js b/src/integration/effort-policy.js new file mode 100644 index 0000000..27b98d4 --- /dev/null +++ b/src/integration/effort-policy.js @@ -0,0 +1,265 @@ +"use strict"; + +const fs = require("node:fs"); +const crypto = require("node:crypto"); +const path = require("node:path"); +const { appendEvent, findSupervisedRun, getNextAction } = require("../supervise/state"); +const { writeJsonAtomic } = require("../workflow/state"); + +const CODEX_EFFORT_POLICY_SCHEMA_VERSION = "codex-effort-policy/v1"; +const CODEX_TASK_CLASSES = Object.freeze([ + "fast-exploration", + "demanding-implementation", + "high-effort-independent-review", +]); +const CODEX_EFFORT_OPERATIONS = Object.freeze(["implementation", "repair", "reviewer"]); +const CODEX_REASONING_EFFORTS = Object.freeze(["minimal", "low", "medium", "high", "xhigh"]); + +function requiredChoice(value, allowed, label) { + if (!allowed.includes(value)) { + throw new Error(`${label} must be one of: ${allowed.join(", ")}.`); + } + return value; +} + +function optionalSelection(value, label) { + if (value === undefined || value === null) return { status: "unknown", value: null }; + if (typeof value !== "string" || value.trim().length === 0 || value.length > 128 || /[\u0000-\u001f]/.test(value)) { + throw new Error(`${label} must be bounded non-empty text.`); + } + return { status: "explicit", value: value.trim() }; +} + +function effortPolicyPath(found) { + return path.join(found.runRoot, "integration", "codex-effort-policy.json"); +} + +function selectionDigest(operation, assignment) { + const content = JSON.stringify({ + operation, + workflow: assignment.workflow, + taskClass: assignment.taskClass, + model: assignment.requested.model, + effort: assignment.requested.effort, + }); + return `sha256:${crypto.createHash("sha256").update(content).digest("hex")}`; +} + +function assignmentSnapshot(assignment) { + if (!assignment) return null; + return { + workflow: assignment.workflow, + taskClass: assignment.taskClass, + requested: assignment.requested, + approval: assignment.approval, + }; +} + +function validateAssignment(operation, assignment, found, options = {}) { + if (!assignment || typeof assignment !== "object") { + throw new Error(`Invalid Codex effort assignment for ${operation}.`); + } + requiredChoice(assignment.taskClass, CODEX_TASK_CLASSES, `${operation}.taskClass`); + if ( + !assignment.workflow + || !Number.isInteger(assignment.workflow.planRevision) + || assignment.workflow.planRevision <= 0 + || typeof assignment.workflow.checkpointId !== "string" + || assignment.workflow.checkpointId.length === 0 + ) { + throw new Error(`Invalid Codex effort workflow binding for ${operation}.`); + } + const model = optionalSelection( + assignment.requested && assignment.requested.model && assignment.requested.model.value, + `${operation}.requested.model`, + ); + const effortValue = assignment.requested && assignment.requested.effort && assignment.requested.effort.value; + if (effortValue !== null && effortValue !== undefined) { + requiredChoice(effortValue, CODEX_REASONING_EFFORTS, `${operation}.requested.effort`); + } + const effort = optionalSelection(effortValue, `${operation}.requested.effort`); + const normalized = { + ...assignment, + requested: { model, effort }, + }; + if ( + !assignment.approval + || assignment.approval.kind !== "operator" + || assignment.approval.selectionDigest !== selectionDigest(operation, normalized) + ) { + throw new Error(`Codex effort assignment for ${operation} is not operator-approved or was modified.`); + } + if ( + options.allowStale !== true + && ( + assignment.workflow.planRevision !== found.run.planRevision + || assignment.workflow.checkpointId !== found.run.tasks[0].id + ) + ) { + throw new Error(`Codex effort assignment for ${operation} was not approved for the current plan revision and checkpoint.`); + } + return normalized; +} + +function loadCodexEffortPolicy(found, options = {}) { + const filePath = effortPolicyPath(found); + if (!fs.existsSync(filePath)) return null; + const policy = JSON.parse(fs.readFileSync(filePath, "utf8")); + if (policy.schemaVersion !== CODEX_EFFORT_POLICY_SCHEMA_VERSION || policy.runId !== found.runId) { + throw new Error(`Invalid Codex effort policy for run ${found.runId}.`); + } + if (policy.provider !== "codex" || policy.automaticModelRouting !== false) { + throw new Error("Invalid Codex effort policy provider or routing boundary."); + } + return { + ...policy, + assignments: Object.fromEntries(Object.entries(policy.assignments || {}).map(([operation, assignment]) => { + requiredChoice(operation, CODEX_EFFORT_OPERATIONS, "effort policy operation"); + return [operation, validateAssignment(operation, assignment, found, options)]; + })), + }; +} + +function approveCodexEffortPolicy(options = {}) { + if (!options.yes) { + throw new Error("Codex effort policy changes require explicit operator approval with --yes."); + } + const found = findSupervisedRun(options); + const operation = requiredChoice(options.operation, CODEX_EFFORT_OPERATIONS, "--operation"); + const taskClass = requiredChoice(options.taskClass, CODEX_TASK_CLASSES, "--task-class"); + if (options.effort !== undefined) { + requiredChoice(options.effort, CODEX_REASONING_EFFORTS, "--effort"); + } + const previous = loadCodexEffortPolicy(found, { allowStale: true }); + const revision = previous ? previous.revision + 1 : 1; + const approvedAt = new Date().toISOString(); + const assignment = { + workflow: { + planRevision: found.run.planRevision, + checkpointId: found.run.tasks[0].id, + }, + taskClass, + requested: { + model: optionalSelection(options.model, "--model"), + effort: optionalSelection(options.effort, "--effort"), + }, + approval: { + kind: "operator", + event: "codex-effort-policy-approved", + revision, + approvedAt, + selectionDigest: null, + }, + }; + assignment.approval.selectionDigest = selectionDigest(operation, assignment); + const previousAssignment = previous ? previous.assignments[operation] || null : null; + const policy = { + schemaVersion: CODEX_EFFORT_POLICY_SCHEMA_VERSION, + runId: found.runId, + provider: "codex", + automaticModelRouting: false, + revision, + updatedAt: approvedAt, + assignments: { + ...(previous ? previous.assignments : {}), + [operation]: assignment, + }, + history: [ + ...(previous ? previous.history : []), + { + revision, + operation, + taskClass, + approvedAt, + previousRevision: previous ? previous.revision : null, + previous: assignmentSnapshot(previousAssignment), + next: assignmentSnapshot(assignment), + }, + ], + }; + const filePath = effortPolicyPath(found); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + writeJsonAtomic(filePath, policy); + appendEvent(found.runRoot, { + schemaVersion: "supervised-event/v1-beta", + timestamp: approvedAt, + type: "codex-effort-policy-approved", + runId: found.runId, + planRevision: found.run.planRevision, + checkpointId: found.run.tasks[0].id, + actor: "operator", + operation, + revision, + selectionDigest: assignment.approval.selectionDigest, + }); + return { + run: found.run, + effortPolicy: policy, + nextAction: getNextAction(found.run), + }; +} + +function resolveCodexEffortForDispatch(found, operation) { + requiredChoice(operation, CODEX_EFFORT_OPERATIONS, "Codex dispatch operation"); + const policy = loadCodexEffortPolicy(found); + const assignment = policy && policy.assignments[operation]; + if (!assignment) { + return { + model: undefined, + effort: undefined, + evidence: { + taskClass: null, + policyRevision: policy ? policy.revision : null, + selectedModel: { status: "unknown", value: null }, + selectedEffort: { status: "unknown", value: null }, + effectiveModel: { status: "unknown", value: null, source: "not-explicitly-selected" }, + effectiveEffort: { status: "unknown", value: null, source: "not-explicitly-selected" }, + }, + }; + } + const model = assignment.requested.model.value || undefined; + const effort = assignment.requested.effort.value || undefined; + return { + model, + effort, + evidence: { + taskClass: assignment.taskClass, + policyRevision: policy.revision, + selectedModel: assignment.requested.model, + selectedEffort: assignment.requested.effort, + effectiveModel: { status: "unknown", value: null, source: model ? "awaiting-supported-turn-evidence" : "not-explicitly-selected" }, + effectiveEffort: { status: "unknown", value: null, source: effort ? "awaiting-supported-turn-evidence" : "not-explicitly-selected" }, + }, + }; +} + +function confirmCodexEffortEvidence(evidence, usage) { + const confirmed = usage && usage.label === "observed"; + const effective = (selected) => ( + confirmed && selected && selected.status === "explicit" + ? { status: "known", value: selected.value, source: "codex-exec-turn-completed-usage" } + : { + status: "unknown", + value: null, + source: selected && selected.status === "explicit" + ? "supported-turn-evidence-unavailable" + : "not-explicitly-selected", + } + ); + return { + ...evidence, + effectiveModel: effective(evidence.selectedModel), + effectiveEffort: effective(evidence.selectedEffort), + }; +} + +module.exports = { + CODEX_EFFORT_OPERATIONS, + CODEX_EFFORT_POLICY_SCHEMA_VERSION, + CODEX_REASONING_EFFORTS, + CODEX_TASK_CLASSES, + approveCodexEffortPolicy, + confirmCodexEffortEvidence, + loadCodexEffortPolicy, + resolveCodexEffortForDispatch, +}; diff --git a/src/integration/hook-evidence.js b/src/integration/hook-evidence.js new file mode 100644 index 0000000..c20dd76 --- /dev/null +++ b/src/integration/hook-evidence.js @@ -0,0 +1,402 @@ +"use strict"; + +const childProcess = require("node:child_process"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const { loadWorkflowRun } = require("../workflow/state"); +const { writeJsonAtomic } = require("../workflow/state"); + +const CODEX_HOOK_TRUST_SCHEMA_VERSION = "codex-hook-trust/v1"; +const CODEX_HOOK_CONTRACT_VERSION = "codex-hooks-doc/2026-07-22"; +const HOOK_BUNDLE_FILES = Object.freeze([ + "hooks/hooks.json", + "hooks/capture-subagent.js", +]); +const SUBAGENT_HOOK_EVIDENCE_SCHEMA_VERSION = "subagent-hook-evidence/v1"; +const SUBAGENT_HOOK_EVENTS = Object.freeze({ + SubagentStart: "subagent-started", + SubagentStop: "subagent-stopped", +}); + +function sha256(value) { + return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`; +} + +function pluginRoot() { + if (process.env.PLUGIN_ROOT) return path.resolve(process.env.PLUGIN_ROOT); + return path.resolve(__dirname, "..", "..", "plugins", "cewp"); +} + +function inspectHookBundle(root = pluginRoot()) { + const files = HOOK_BUNDLE_FILES.map((relativePath) => { + const filePath = path.join(root, ...relativePath.split("/")); + if (!fs.existsSync(filePath)) throw new Error(`Codex hook bundle file is missing: ${relativePath}.`); + const content = fs.readFileSync(filePath); + return { path: relativePath, digest: sha256(content), content }; + }); + const bundle = Buffer.concat(files.flatMap((file) => [ + Buffer.from(`${file.path}\0`, "utf8"), + file.content, + Buffer.from("\0", "utf8"), + ])); + return { + digest: sha256(bundle), + files: files.map(({ path: relativePath, digest }) => ({ path: relativePath, digest })), + }; +} + +function detectCodexVersion(options = {}) { + if (process.env.CEWP_HOOK_CODEX_VERSION) return process.env.CEWP_HOOK_CODEX_VERSION.trim(); + const command = options.command || "codex"; + const result = childProcess.spawnSync(command, ["--version"], { + encoding: "utf8", + windowsHide: true, + timeout: 10000, + }); + if (result.status !== 0 || !String(result.stdout || "").trim()) { + throw new Error("Codex hook approval requires a successful local `codex --version` probe."); + } + return String(result.stdout).trim(); +} + +function detectCewpVersion() { + const packagePath = path.resolve(__dirname, "..", "..", "package.json"); + if (!fs.existsSync(packagePath)) { + throw new Error("Codex hook evidence requires the complete CEWP package, including package.json."); + } + const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); + if (typeof packageJson.version !== "string" || packageJson.version.trim().length === 0) { + throw new Error("Codex hook evidence could not determine the CEWP runtime version."); + } + return packageJson.version.trim(); +} + +function trustPath(found) { + return path.join(found.runRoot, "integration", "codex-hook-trust.json"); +} + +function activationPath(repoRoot) { + return path.join(repoRoot, ".cewp", "integration", "active-hook-trust.json"); +} + +function boundedText(value, label, maximum, optional = false) { + if (optional && (value === null || value === undefined)) return null; + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`Invalid Codex subagent hook event: ${label} is required.`); + } + const text = value.trim(); + if (text.length > maximum || /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(text)) { + throw new Error(`Invalid Codex subagent hook event: ${label} is too long or contains control characters.`); + } + return text; +} + +function findActivatedRepoRoot(startPath) { + let current = path.resolve(startPath); + while (true) { + if (fs.existsSync(activationPath(current))) return current; + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } +} + +function validateActiveTrust(repoRoot, currentCodexVersion, root = pluginRoot(), currentCewpVersion) { + const activePath = activationPath(repoRoot); + if (!fs.existsSync(activePath)) return null; + const active = JSON.parse(fs.readFileSync(activePath, "utf8")); + if (active.schemaVersion !== "codex-hook-activation/v1") { + throw new Error("Codex hook evidence activation is malformed; approve the hook bundle again."); + } + const found = loadWorkflowRun(repoRoot, active.runId); + const filePath = trustPath(found); + if (!fs.existsSync(filePath)) throw new Error("Codex hook trust receipt is missing; approve the hook bundle again."); + const trust = JSON.parse(fs.readFileSync(filePath, "utf8")); + if (trust.schemaVersion !== CODEX_HOOK_TRUST_SCHEMA_VERSION || trust.provider !== "codex") { + throw new Error("Codex hook trust receipt is malformed; approve the hook bundle again."); + } + const selection = { + runId: trust.runId, + workflowRevision: trust.workflowRevision, + workflowDigest: trust.workflowDigest, + cewpVersion: trust.cewpVersion, + codexVersion: trust.codexVersion, + hookContractVersion: trust.hookContractVersion, + bundleDigest: trust.bundleDigest, + }; + const approvalDigest = sha256(JSON.stringify(selection)); + if ( + !trust.approval + || trust.approval.kind !== "operator" + || trust.approval.digest !== approvalDigest + || active.trustDigest !== approvalDigest + ) { + throw new Error("Codex hook trust approval digest changed; review and approve the hook bundle again."); + } + const currentBundle = inspectHookBundle(root); + if (trust.bundleDigest !== currentBundle.digest) { + throw new Error("Codex hook definition drift detected; review and approve the current bundle again."); + } + if (trust.codexVersion !== currentCodexVersion) { + throw new Error(`Codex hook version drift detected: approved ${trust.codexVersion}, observed ${currentCodexVersion}.`); + } + const observedCewpVersion = currentCewpVersion || detectCewpVersion(); + if (trust.cewpVersion !== observedCewpVersion) { + throw new Error(`CEWP hook runtime drift detected: approved ${trust.cewpVersion}, observed ${observedCewpVersion}.`); + } + if (trust.hookContractVersion !== CODEX_HOOK_CONTRACT_VERSION) { + throw new Error("Codex hook contract drift detected; review and approve the current contract again."); + } + if ( + trust.runId !== found.run.runId + || trust.workflowRevision !== found.run.workflow.revision + || trust.workflowDigest !== found.run.workflow.digest + ) { + throw new Error("Codex hook trust is stale for the current workflow revision."); + } + return { active, found, trust }; +} + +function recordSubagentHookEvent(options = {}) { + const input = options.input; + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new Error("Invalid Codex subagent hook event: expected one JSON object."); + } + const eventCwd = boundedText(input.cwd, "cwd", 4096); + const repoRoot = findActivatedRepoRoot(options.repoRoot || eventCwd); + if (!repoRoot) return { recorded: false, reason: "not-activated" }; + const resolvedCwd = path.resolve(eventCwd); + const relativeCwd = path.relative(repoRoot, resolvedCwd); + if (relativeCwd.startsWith("..") || path.isAbsolute(relativeCwd)) { + throw new Error("Invalid Codex subagent hook event: cwd is outside the activated repository."); + } + const currentCodexVersion = options.codexVersion || detectCodexVersion(options); + const active = validateActiveTrust(repoRoot, currentCodexVersion, options.pluginRoot, options.cewpVersion); + if (!active) return { recorded: false, reason: "not-activated" }; + const hookEventName = boundedText(input.hook_event_name, "hook_event_name", 64); + const type = SUBAGENT_HOOK_EVENTS[hookEventName]; + if (!type) throw new Error(`Invalid Codex subagent hook event: unsupported event ${hookEventName}.`); + const agentId = boundedText(input.agent_id, "agent_id", 512); + const parentSessionId = boundedText(input.session_id, "session_id", 512); + const parentTurnId = boundedText(input.turn_id, "turn_id", 512); + const agentType = boundedText(input.agent_type, "agent_type", 128); + const model = boundedText(input.model, "model", 256); + const permissionMode = boundedText(input.permission_mode, "permission_mode", 64); + const summaryValue = hookEventName === "SubagentStop" + ? boundedText(input.last_assistant_message, "last_assistant_message", 4000, true) + : null; + const observedAt = (options.now || new Date()).toISOString(); + const evidence = { + schemaVersion: SUBAGENT_HOOK_EVIDENCE_SCHEMA_VERSION, + eventId: sha256(JSON.stringify({ + runId: active.found.run.runId, + hookEventName, + parentSessionId, + parentTurnId, + agentId, + observedAt, + })), + type, + observedAt, + workflow: { + runId: active.found.run.runId, + revision: active.found.run.workflow.revision, + digest: active.found.run.workflow.digest, + }, + source: { + path: "plugin-hook", + evidenceClass: "observed", + codexVersion: currentCodexVersion, + cewpVersion: active.trust.cewpVersion, + hookContractVersion: CODEX_HOOK_CONTRACT_VERSION, + bundleDigest: active.trust.bundleDigest, + trustApprovalDigest: active.trust.approval.digest, + }, + references: { + agentId, + agentType, + parentSessionId, + parentTurnId, + agentThreadId: { status: "unknown", value: null, reason: "not-exposed-by-documented-hook-input" }, + }, + context: { + model, + permissionMode, + workingDirectory: path.relative(repoRoot, resolvedCwd).replace(/\\/g, "/") || ".", + }, + summary: summaryValue + ? { status: "observed", value: summaryValue } + : { status: "unknown", value: null }, + claims: { + coreEnforcement: false, + opensCoreGates: false, + transcriptRead: false, + }, + }; + const ledgerPath = path.join(active.found.runRoot, "integration", "subagent-hook-evidence.jsonl"); + fs.mkdirSync(path.dirname(ledgerPath), { recursive: true }); + fs.appendFileSync(ledgerPath, `${JSON.stringify(evidence)}\n`); + return { recorded: true, evidence }; +} + +function approveCodexHookTrust(options = {}) { + if (!options.yes) { + throw new Error("Codex hook evidence requires explicit operator approval with --yes after reviewing the bundle and `/hooks`."); + } + const found = loadWorkflowRun(options.repoRoot || process.cwd(), options.runId); + const bundle = inspectHookBundle(options.pluginRoot); + const codexVersion = options.codexVersion || detectCodexVersion(options); + const approvedAt = (options.now || new Date()).toISOString(); + const selection = { + runId: found.run.runId, + workflowRevision: found.run.workflow.revision, + workflowDigest: found.run.workflow.digest, + cewpVersion: options.cewpVersion || detectCewpVersion(), + codexVersion, + hookContractVersion: CODEX_HOOK_CONTRACT_VERSION, + bundleDigest: bundle.digest, + }; + const trust = { + schemaVersion: CODEX_HOOK_TRUST_SCHEMA_VERSION, + provider: "codex", + ...selection, + bundleFiles: bundle.files, + approvedAt, + approval: { + kind: "operator", + digest: sha256(JSON.stringify(selection)), + }, + hostTrust: { + required: true, + status: "pending-review", + reviewCommand: "/hooks", + }, + provenance: { + source: "official-documentation", + url: "https://learn.chatgpt.com/docs/hooks", + }, + claims: { + coreEnforcement: false, + opensCoreGates: false, + transcriptRead: false, + }, + }; + const filePath = trustPath(found); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + writeJsonAtomic(filePath, trust); + const activePath = activationPath(found.repoRoot); + fs.mkdirSync(path.dirname(activePath), { recursive: true }); + writeJsonAtomic(activePath, { + schemaVersion: "codex-hook-activation/v1", + runId: found.run.runId, + trustDigest: trust.approval.digest, + activatedAt: approvedAt, + }); + fs.appendFileSync(path.join(found.runRoot, "events.jsonl"), `${JSON.stringify({ + schemaVersion: "workflow-event/v1", + timestamp: approvedAt, + type: "hook-evidence-approved", + runId: found.run.runId, + revision: found.run.workflow.revision, + actor: "operator", + approvalDigest: trust.approval.digest, + })}\n`); + return { + trust, + nextAction: { + kind: "host-hook-review", + command: "/hooks", + reason: "Codex separately reviews and trusts the exact current plugin hook definition.", + }, + }; +} + +function classifyCompatibilityWarning(error) { + const message = error && error.message ? error.message : String(error); + const code = /Codex hook version drift/.test(message) + ? "codex-version-drift" + : /definition drift/.test(message) + ? "hook-definition-drift" + : /runtime drift/.test(message) + ? "cewp-version-drift" + : /contract drift/.test(message) + ? "hook-contract-drift" + : /workflow revision|not active/.test(message) + ? "workflow-binding-drift" + : /approval digest|trust receipt/.test(message) + ? "hook-trust-change" + : "hook-evidence-unavailable"; + return { code, message }; +} + +function inspectCodexHookTrust(options = {}) { + const repoRoot = path.resolve(options.repoRoot || process.cwd()); + const found = loadWorkflowRun(repoRoot, options.runId); + const filePath = trustPath(found); + const claims = { + coreEnforcement: false, + opensCoreGates: false, + hostTrustInferred: false, + }; + if (!fs.existsSync(filePath)) { + return { + schemaVersion: "codex-hook-status/v1", + runId: found.run.runId, + compatible: false, + active: false, + trust: null, + warnings: [{ code: "hook-evidence-not-approved", message: "No approved hook evidence bundle exists for this run." }], + fallback: "core-and-conversation-only", + claims, + }; + } + let trust = null; + try { + try { + trust = JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (error) { + throw new Error(`Codex hook trust receipt is malformed: ${error.message}`); + } + const currentCodexVersion = options.codexVersion || detectCodexVersion(options); + const active = validateActiveTrust(repoRoot, currentCodexVersion, options.pluginRoot, options.cewpVersion); + if (!active || active.found.run.runId !== found.run.runId) { + throw new Error("Codex hook trust is not active for the requested workflow run."); + } + return { + schemaVersion: "codex-hook-status/v1", + runId: found.run.runId, + compatible: true, + active: true, + trust, + warnings: [], + fallback: null, + claims, + }; + } catch (error) { + return { + schemaVersion: "codex-hook-status/v1", + runId: found.run.runId, + compatible: false, + active: false, + trust, + warnings: [classifyCompatibilityWarning(error)], + fallback: "core-and-conversation-only", + claims, + }; + } +} + +module.exports = { + CODEX_HOOK_CONTRACT_VERSION, + CODEX_HOOK_TRUST_SCHEMA_VERSION, + SUBAGENT_HOOK_EVIDENCE_SCHEMA_VERSION, + approveCodexHookTrust, + detectCewpVersion, + detectCodexVersion, + findActivatedRepoRoot, + inspectCodexHookTrust, + inspectHookBundle, + recordSubagentHookEvent, + validateActiveTrust, +}; diff --git a/src/run/adapters/codex-exec.js b/src/run/adapters/codex-exec.js index 2e91d81..b5d3ec8 100644 --- a/src/run/adapters/codex-exec.js +++ b/src/run/adapters/codex-exec.js @@ -198,14 +198,20 @@ function buildCodexExecInvocation({ outputLastMessagePath, sandbox = "workspace-write", structuredJson = false, + model, + effort, }) { const formatArgs = structuredJson ? ["--json"] : []; + const modelArgs = model ? ["--model", model] : []; + const effortArgs = effort ? ["--config", `model_reasoning_effort="${effort}"`] : []; return { command: command || "codex", args: [ ...prefixArgs, "exec", ...formatArgs, + ...modelArgs, + ...effortArgs, "--cd", worktreePath, "--sandbox", @@ -218,7 +224,7 @@ function buildCodexExecInvocation({ }; } -function runCodexExecAdapter({ worktreePath, promptPath, outputLastMessagePath, timeoutSeconds, sandbox = "workspace-write", structuredJson = false }) { +function runCodexExecAdapter({ worktreePath, promptPath, outputLastMessagePath, timeoutSeconds, sandbox = "workspace-write", structuredJson = false, model, effort }) { validateTimeoutSeconds(timeoutSeconds); const prompt = fs.readFileSync(promptPath, "utf8"); const invocation = buildCodexExecInvocation({ @@ -229,6 +235,8 @@ function runCodexExecAdapter({ worktreePath, promptPath, outputLastMessagePath, outputLastMessagePath, sandbox, structuredJson, + model, + effort, }); return childProcess.spawnSync(invocation.command, invocation.args, { diff --git a/src/run/ownership.js b/src/run/ownership.js index ebf7846..9bd88a2 100644 --- a/src/run/ownership.js +++ b/src/run/ownership.js @@ -1,5 +1,6 @@ "use strict"; +const fs = require("node:fs"); const path = require("node:path"); const { normalizeComparePath } = require("../lib/paths"); @@ -76,11 +77,49 @@ function findOwnershipConflict(records, requested, options = {}) { return undefined; } +function loadOwnershipRecords(repoRoot, options = {}) { + const recordsRoot = path.join(repoRoot, ".cewp"); + if (!fs.existsSync(recordsRoot)) return []; + const excludedPath = options.excludePath + ? normalizeComparePath(path.resolve(options.excludePath)) + : null; + const records = []; + const pending = [recordsRoot]; + + while (pending.length > 0) { + const directory = pending.pop(); + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory() && !entry.isSymbolicLink()) { + pending.push(entryPath); + continue; + } + if ( + !entry.isFile() + || entry.name !== "ownership.json" + || normalizeComparePath(path.resolve(entryPath)) === excludedPath + ) { + continue; + } + let record; + try { + record = JSON.parse(fs.readFileSync(entryPath, "utf8")); + } catch (error) { + throw new Error(`Invalid execution ownership registry entry ${entryPath}: ${error.message}`); + } + records.push(validateOwnershipRecord(record)); + } + } + + return records; +} + module.exports = { EXECUTION_OWNERS, MANAGED_BACKENDS, OWNERSHIP_SCHEMA_VERSION, findOwnershipConflict, + loadOwnershipRecords, normalizeWorktreePath, validateOwnershipRecord, }; diff --git a/src/supervise/cli.js b/src/supervise/cli.js index cea23e3..f8811f4 100644 --- a/src/supervise/cli.js +++ b/src/supervise/cli.js @@ -10,6 +10,7 @@ const { verifySupervisedCheckpoint } = require("./verification"); const { reviewSupervisedCheckpoint } = require("./review"); const { finalizeSupervisedRun, previewSupervisedReceipt } = require("./receipt"); const { runSupervisedControl } = require("./controls"); +const { approveCodexEffortPolicy } = require("../integration/effort-policy"); const CONTROL_COMMANDS = new Set([ "revise", "pause", "resume", "add-budget", "rollback", "cancel", "abandon", "block", "continue", "reassign", @@ -99,6 +100,19 @@ function runSupervise(options = {}) { return; } + if (options.subcommand === "effort") { + const result = approveCodexEffortPolicy({ + ...options, + repoRoot: process.cwd(), + }); + if (options.json) { + outputJson("supervise.effort", result); + } else { + printStatus("CEWP Codex effort policy approved", result); + } + return; + } + if (options.subcommand === "execute") { const result = executeSupervisedCheckpoint({ ...options, diff --git a/src/supervise/execution.js b/src/supervise/execution.js index 3381da7..0320ad1 100644 --- a/src/supervise/execution.js +++ b/src/supervise/execution.js @@ -23,8 +23,13 @@ const { } = require("../run/adapters/codex-exec"); const { OWNERSHIP_SCHEMA_VERSION, + loadOwnershipRecords, validateOwnershipRecord, } = require("../run/ownership"); +const { + confirmCodexEffortEvidence, + resolveCodexEffortForDispatch, +} = require("../integration/effort-policy"); const { appendEvent, findSupervisedRun, @@ -210,27 +215,7 @@ function createOwnedWorktree(found, startedAt) { if (continued) return continued; const paths = getWorktreePaths(found); - if (fs.existsSync(paths.worktreePath)) { - throw new Error(`Managed checkpoint worktree already exists: ${paths.worktreePath}`); - } - const branchProbe = getGitOutput(["show-ref", "--verify", "--quiet", `refs/heads/${paths.branch}`], found.repoRoot); - if (branchProbe.status === 0) { - throw new Error(`Managed checkpoint branch already exists: ${paths.branch}`); - } - - fs.mkdirSync(path.dirname(paths.worktreePath), { recursive: true }); - const created = getGitOutput([ - "worktree", - "add", - paths.worktreePath, - "-b", - paths.branch, - checkpointBaseCommit(found.run, found.run.tasks[0]), - ], found.repoRoot); - if (created.status !== 0) { - throw new Error(`Failed to create managed checkpoint worktree: ${(created.stderr || created.stdout || "").trim()}`); - } - + const ownershipPath = path.join(found.runRoot, "ownership.json"); const ownership = validateOwnershipRecord({ schemaVersion: OWNERSHIP_SCHEMA_VERSION, runId: found.runId, @@ -254,13 +239,34 @@ function createOwnedWorktree(found, startedAt) { app: false, notification: false, }, - ownershipRecords: [], + ownershipRecords: loadOwnershipRecords(found.repoRoot, { excludePath: ownershipPath }), requestedOwnership: ownership, }, { repoRoot: found.repoRoot }); if (!gate.allowed) { throw new Error(`Controlled operation blocked: ${gate.reason}`); } - writeJsonAtomic(path.join(found.runRoot, "ownership.json"), ownership); + if (fs.existsSync(paths.worktreePath)) { + throw new Error(`Managed checkpoint worktree already exists: ${paths.worktreePath}`); + } + const branchProbe = getGitOutput(["show-ref", "--verify", "--quiet", `refs/heads/${paths.branch}`], found.repoRoot); + if (branchProbe.status === 0) { + throw new Error(`Managed checkpoint branch already exists: ${paths.branch}`); + } + + fs.mkdirSync(path.dirname(paths.worktreePath), { recursive: true }); + const created = getGitOutput([ + "worktree", + "add", + paths.worktreePath, + "-b", + paths.branch, + checkpointBaseCommit(found.run, found.run.tasks[0]), + ], found.repoRoot); + if (created.status !== 0) { + throw new Error(`Failed to create managed checkpoint worktree: ${(created.stderr || created.stdout || "").trim()}`); + } + + writeJsonAtomic(ownershipPath, ownership); return { ...paths, ownership }; } @@ -301,6 +307,7 @@ function executeSupervisedCheckpoint(options = {}) { assertPolicyAllows(found.repoRoot, "runWorkers"); assertPolicyAllows(found.repoRoot, "runCommands"); enforceOperationBudget(found, "implementation"); + const codexSelection = resolveCodexEffortForDispatch(found, "implementation"); const startedAt = new Date().toISOString(); const owned = createOwnedWorktree(found, startedAt); @@ -330,6 +337,7 @@ function executeSupervisedCheckpoint(options = {}) { scope: { status: "pending", warnings: [] }, testAuthoring: { policy: found.run.assurance.testAuthoring, status: "pending", violations: [] }, usage: { label: "unknown", value: null }, + codex: codexSelection.evidence, }; let startedRun = { ...found.run, @@ -396,6 +404,8 @@ function executeSupervisedCheckpoint(options = {}) { timeoutSeconds: options.timeoutSeconds, sandbox: "workspace-write", structuredJson: true, + model: codexSelection.model, + effort: codexSelection.effort, }); const remainingOutput = Math.max( 0, @@ -439,6 +449,7 @@ function executeSupervisedCheckpoint(options = {}) { }, testAuthoring, usage, + codex: confirmCodexEffortEvidence(attempt.codex, usage), logs: { stdout: path.relative(found.runRoot, stdoutPath).replace(/\\/g, "/"), stderr: path.relative(found.runRoot, stderrPath).replace(/\\/g, "/"), @@ -549,6 +560,7 @@ function retrySupervisedCheckpoint(options = {}) { } assertPolicyAllows(found.repoRoot, "runWorkers"); enforceOperationBudget(found, "repair"); + const codexSelection = resolveCodexEffortForDispatch(found, "repair"); const repairCount = task.attempts.filter((attempt) => attempt.kind === "repair").length; if (repairCount >= found.run.budget.maxRepairsPerCheckpoint.value) { throw new Error("Checkpoint repair limit is exhausted; explicit budget revision is required."); @@ -592,6 +604,7 @@ function retrySupervisedCheckpoint(options = {}) { scope: { status: "pending", warnings: [] }, testAuthoring: { policy: found.run.assurance.testAuthoring, status: "pending", violations: [] }, usage: { label: "unknown", value: null }, + codex: codexSelection.evidence, }; let startedRun = { ...found.run, @@ -638,6 +651,8 @@ function retrySupervisedCheckpoint(options = {}) { timeoutSeconds: options.timeoutSeconds, sandbox: "workspace-write", structuredJson: true, + model: codexSelection.model, + effort: codexSelection.effort, }); const remainingOutput = Math.max( 0, @@ -679,6 +694,7 @@ function retrySupervisedCheckpoint(options = {}) { }, testAuthoring, usage, + codex: confirmCodexEffortEvidence(attempt.codex, usage), logs: { stdout: path.relative(found.runRoot, stdoutPath).replace(/\\/g, "/"), stderr: path.relative(found.runRoot, stderrPath).replace(/\\/g, "/"), diff --git a/src/supervise/review.js b/src/supervise/review.js index d502c7e..40dbedc 100644 --- a/src/supervise/review.js +++ b/src/supervise/review.js @@ -10,6 +10,10 @@ const { runCodexExecAdapter, } = require("../run/adapters/codex-exec"); const { validateOwnershipRecord } = require("../run/ownership"); +const { + confirmCodexEffortEvidence, + resolveCodexEffortForDispatch, +} = require("../integration/effort-policy"); const { applyThresholdObservation, enforceOperationBudget } = require("./budget"); const { mergeManagedUsage, @@ -80,6 +84,7 @@ function reviewSupervisedCheckpoint(options = {}) { } assertPolicyAllows(found.repoRoot, "runReviewer"); enforceOperationBudget(found, "reviewer"); + const codexSelection = resolveCodexEffortForDispatch(found, "reviewer"); const ownership = validateOwnershipRecord( readJsonFile(path.join(found.runRoot, "ownership.json"), "execution ownership"), ); @@ -114,6 +119,7 @@ function reviewSupervisedCheckpoint(options = {}) { independent: true, status: "executing", startedAt, + codex: codexSelection.evidence, }, }; startedRun.budget.consumed.modelOperations += 1; @@ -147,6 +153,8 @@ function reviewSupervisedCheckpoint(options = {}) { timeoutSeconds: options.timeoutSeconds, sandbox: "read-only", structuredJson: true, + model: codexSelection.model, + effort: codexSelection.effort, }); const remainingOutput = Math.max( 0, @@ -188,6 +196,7 @@ function reviewSupervisedCheckpoint(options = {}) { timedOut, reportPath: path.relative(found.runRoot, reportPath).replace(/\\/g, "/"), usage, + codex: confirmCodexEffortEvidence(startedRun.reviewer.codex, usage), reason: decision ? null : "Reviewer output did not contain a supported Decision line.", diff --git a/tests/contracts/integration-binding.js b/tests/contracts/integration-binding.js index 91da018..0fd7515 100644 --- a/tests/contracts/integration-binding.js +++ b/tests/contracts/integration-binding.js @@ -12,7 +12,7 @@ const { createHostBinding, loadHostBinding, } = require("../../src/integration/binding"); -const { loadWorkflowRun } = require("../../src/workflow/state"); +const { loadWorkflowRun, startWorkflowTask } = require("../../src/workflow/state"); function assertThrows(action, expected, label) { let error; @@ -129,6 +129,56 @@ function main() { cleanupRepo(managedRepo); } + const conflictRepo = makeTempRepo("cewp-integration-ownership-conflict-"); + try { + const native = approveWorkflow(conflictRepo, nativeDefinition()); + let nativeFound = loadWorkflowRun(conflictRepo, native.runId); + const started = startWorkflowTask(nativeFound, "implement-example", { + now: new Date("2026-07-18T12:01:00.000Z"), + }); + nativeFound = loadWorkflowRun(conflictRepo, native.runId); + const sharedWorktree = path.join(conflictRepo, "..", ".cewp-worktrees", "shared-task"); + const managedOwnershipPath = path.join( + conflictRepo, + ".cewp", + "supervised-runs", + "managed-conflict", + "ownership.json", + ); + fs.mkdirSync(path.dirname(managedOwnershipPath), { recursive: true }); + fs.writeFileSync(managedOwnershipPath, `${JSON.stringify({ + schemaVersion: "execution-ownership/v1", + runId: "managed-conflict", + taskId: "implement-example", + checkpointId: "implement-example", + owner: "managed", + backend: "codex-exec", + status: "active", + createdAt: "2026-07-18T12:00:00.000Z", + cleanupAuthority: "cewp-core", + worktree: { id: "shared-task", path: sharedWorktree }, + }, null, 2)}\n`); + + const conflictingBinding = explicitBinding(native.runId); + conflictingBinding.workflow = { + runId: native.runId, + taskId: "implement-example", + checkpointId: started.checkpoint.checkpointId, + }; + conflictingBinding.references.worktree = { id: "shared-task", path: sharedWorktree }; + assertThrows( + () => createHostBinding(nativeFound, conflictingBinding, { capabilities: supportedSnapshot() }), + /execution ownership conflict/, + "native host binding cannot claim an active managed task worktree", + ); + assert( + !fs.existsSync(path.join(nativeFound.runRoot, "integration", "host-binding.json")), + "conflicting native binding is not persisted", + ); + } finally { + cleanupRepo(conflictRepo); + } + const coreRun = JSON.parse(fs.readFileSync(found.runPath, "utf8")); assert(coreRun.host === undefined && coreRun.references === undefined, "provider ids stay outside core schema"); diff --git a/tests/contracts/integration-effort-policy.js b/tests/contracts/integration-effort-policy.js new file mode 100644 index 0000000..b07f566 --- /dev/null +++ b/tests/contracts/integration-effort-policy.js @@ -0,0 +1,271 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { assert } = require("../harness/lib/assertions"); +const { createFakeCodexAdapter } = require("../harness/lib/fake-adapter"); +const { cleanupRepo, makeTempRepo, runNode } = require("../harness/lib/temp-repo"); + +const cewpCli = path.join(__dirname, "..", "..", "bin", "cewp.js"); + +function parseJson(result, label) { + assert(result.status === 0, `${label} failed: ${result.stderr}`); + return JSON.parse(result.stdout); +} + +function createApprovedRun(repoRoot) { + const planned = parseJson(runNode(cewpCli, [ + "supervise", "plan", + "--goal", "Inspect one bounded behavior", + "--scope", "README.md", + "--verify", "git diff --check", + "--stop", "The bounded behavior is inspected", + "--json", + ], repoRoot), "supervise plan"); + const runId = planned.data.run.runId; + parseJson(runNode(cewpCli, [ + "supervise", "approve", runId, "--yes", "--json", + ], repoRoot), "supervise approve"); + return runId; +} + +function createApprovedRepairRun(repoRoot) { + const verification = "node -e \"const fs=require('fs'); process.exit(fs.readFileSync('README.md','utf8').includes('Fake Codex')?1:0)\""; + const planned = parseJson(runNode(cewpCli, [ + "supervise", "plan", + "--goal", "Repair one bounded regression", + "--scope", "README.md", + "--verify", verification, + "--stop", "The bounded regression is repaired", + "--json", + ], repoRoot), "repair supervise plan"); + const runId = planned.data.run.runId; + parseJson(runNode(cewpCli, ["supervise", "approve", runId, "--yes", "--json"], repoRoot), "repair supervise approve"); + return runId; +} + +function runRepairEffortContract() { + const repoRoot = makeTempRepo("cewp-repair-effort-policy-"); + const fake = createFakeCodexAdapter(); + try { + const runId = createApprovedRepairRun(repoRoot); + assert(runNode(cewpCli, ["policy", "set", "full-authority"], repoRoot).status === 0, "repair fixture grants dispatch authority"); + parseJson(runNode(cewpCli, [ + "supervise", "execute", runId, "--yes", "--timeout", "20", "--json", + ], repoRoot, { env: fake.env }), "execute repair fixture"); + const failedVerification = runNode(cewpCli, [ + "supervise", "verify", runId, "--timeout", "20", "--json", + ], repoRoot); + assert(failedVerification.status === 1, "repair fixture reaches a failed verification gate"); + const failed = JSON.parse(failedVerification.stdout); + assert(failed.data.run.status === "needs-repair", "failed verification exposes bounded repair"); + + parseJson(runNode(cewpCli, [ + "supervise", "effort", runId, + "--operation", "repair", + "--task-class", "demanding-implementation", + "--model", "gpt-test-repair", + "--effort", "medium", + "--yes", "--json", + ], repoRoot), "approve repair effort policy"); + const retried = parseJson(runNode(cewpCli, [ + "supervise", "retry", runId, "--yes", "--timeout", "20", "--json", + ], repoRoot, { + env: { + ...fake.env, + CEWP_FAKE_CODEX_EXPECT_MODEL: "gpt-test-repair", + CEWP_FAKE_CODEX_EXPECT_EFFORT: "medium", + }, + }), "retry with approved effort policy"); + const repairAttempt = retried.data.run.tasks[0].attempts.at(-1); + assert(repairAttempt.kind === "repair", "repair attempt remains explicitly classified"); + assert(repairAttempt.codex.taskClass === "demanding-implementation", "repair evidence retains its task class"); + assert(repairAttempt.codex.effectiveModel.value === "gpt-test-repair", "repair evidence records the effective model"); + assert(repairAttempt.codex.effectiveEffort.value === "medium", "repair evidence records the effective effort"); + } finally { + fs.rmSync(fake.fakeRoot, { recursive: true, force: true }); + cleanupRepo(repoRoot); + } +} + +function runEffortTamperContract() { + const repoRoot = makeTempRepo("cewp-effort-policy-tamper-"); + const fake = createFakeCodexAdapter(); + try { + const runId = createApprovedRun(repoRoot); + parseJson(runNode(cewpCli, [ + "supervise", "effort", runId, + "--operation", "implementation", + "--task-class", "demanding-implementation", + "--model", "gpt-approved", + "--effort", "high", + "--yes", "--json", + ], repoRoot), "approve tamper fixture policy"); + const policyPath = path.join(repoRoot, ".cewp", "supervised-runs", runId, "integration", "codex-effort-policy.json"); + const policy = JSON.parse(fs.readFileSync(policyPath, "utf8")); + policy.assignments.implementation.requested.model.value = "gpt-unapproved-edit"; + fs.writeFileSync(policyPath, `${JSON.stringify(policy, null, 2)}\n`); + assert(runNode(cewpCli, ["policy", "set", "full-authority"], repoRoot).status === 0, "tamper fixture grants dispatch authority"); + const refused = runNode(cewpCli, [ + "supervise", "execute", runId, "--yes", "--timeout", "20", "--json", + ], repoRoot, { env: fake.env }); + assert(refused.status === 1, "modified effort sidecar cannot dispatch"); + assert(refused.stderr.includes("not operator-approved or was modified"), "tamper refusal explains the approval failure"); + } finally { + fs.rmSync(fake.fakeRoot, { recursive: true, force: true }); + cleanupRepo(repoRoot); + } +} + +function runStalePlanRevisionContract() { + const repoRoot = makeTempRepo("cewp-effort-policy-stale-revision-"); + const fake = createFakeCodexAdapter(); + try { + const runId = createApprovedRun(repoRoot); + parseJson(runNode(cewpCli, [ + "supervise", "effort", runId, + "--operation", "implementation", + "--task-class", "demanding-implementation", + "--model", "gpt-old-plan", + "--effort", "high", + "--yes", "--json", + ], repoRoot), "approve old-plan effort policy"); + const revised = parseJson(runNode(cewpCli, [ + "supervise", "revise", runId, + "--goal", "Inspect a revised bounded behavior", + "--json", + ], repoRoot), "revise effort-policy plan"); + assert(revised.data.run.planRevision === 2, "fixture creates a new plan revision"); + parseJson(runNode(cewpCli, ["supervise", "approve", runId, "--yes", "--json"], repoRoot), "approve revised plan"); + assert(runNode(cewpCli, ["policy", "set", "full-authority"], repoRoot).status === 0, "stale revision fixture grants dispatch authority"); + const refused = runNode(cewpCli, [ + "supervise", "execute", runId, "--yes", "--timeout", "20", "--json", + ], repoRoot, { env: fake.env }); + assert(refused.status === 1, "old-plan effort approval cannot dispatch a revised plan"); + assert(refused.stderr.includes("current plan revision"), "stale approval refusal names the plan-revision mismatch"); + } finally { + fs.rmSync(fake.fakeRoot, { recursive: true, force: true }); + cleanupRepo(repoRoot); + } +} + +function main() { + const repoRoot = makeTempRepo("cewp-effort-policy-"); + const fake = createFakeCodexAdapter(); + try { + const runId = createApprovedRun(repoRoot); + const unapproved = runNode(cewpCli, [ + "supervise", "effort", runId, + "--operation", "implementation", + "--task-class", "fast-exploration", + "--json", + ], repoRoot); + assert(unapproved.status === 1, "effort changes require explicit --yes approval"); + assert(unapproved.stderr.includes("explicit operator approval"), "approval refusal is actionable"); + const configured = parseJson(runNode(cewpCli, [ + "supervise", "effort", runId, + "--operation", "implementation", + "--task-class", "fast-exploration", + "--yes", "--json", + ], repoRoot), "supervise effort"); + + assert(configured.command === "supervise.effort", "effort command identifies its public operation"); + const policy = configured.data.effortPolicy; + assert(policy.schemaVersion === "codex-effort-policy/v1", "effort policy is versioned"); + assert(policy.provider === "codex", "provider identity stays in the integration sidecar"); + assert(policy.automaticModelRouting === false, "automatic model routing remains disabled"); + assert(policy.assignments.implementation.taskClass === "fast-exploration", "explicit task class is retained"); + assert(policy.assignments.implementation.requested.model.status === "unknown", "task class does not infer a model"); + assert(policy.assignments.implementation.requested.effort.status === "unknown", "task class does not infer effort"); + assert(policy.assignments.implementation.approval.kind === "operator", "operator approval is recorded"); + const canonicalRun = JSON.parse(fs.readFileSync( + path.join(repoRoot, ".cewp", "supervised-runs", runId, "run.json"), + "utf8", + )); + assert(canonicalRun.effortPolicy === undefined, "provider-specific effort policy stays outside canonical run state"); + + const explicit = parseJson(runNode(cewpCli, [ + "supervise", "effort", runId, + "--operation", "implementation", + "--task-class", "demanding-implementation", + "--model", "gpt-test-explicit", + "--effort", "high", + "--yes", "--json", + ], repoRoot), "approve explicit model and effort"); + assert(explicit.data.effortPolicy.revision === 2, "approved setting change creates a new revision"); + const change = explicit.data.effortPolicy.history.at(-1); + assert(change.previous.taskClass === "fast-exploration", "change history retains the previous task class"); + assert(change.previous.requested.model.status === "unknown", "change history retains the previous unknown model"); + assert(change.next.taskClass === "demanding-implementation", "change history retains the next task class"); + assert(change.next.requested.model.value === "gpt-test-explicit", "change history retains the approved next model"); + assert(change.next.requested.effort.value === "high", "change history retains the approved next effort"); + const approvalEvents = fs.readFileSync( + path.join(repoRoot, ".cewp", "supervised-runs", runId, "events.jsonl"), + "utf8", + ).trim().split("\n").map((line) => JSON.parse(line)) + .filter((event) => event.type === "codex-effort-policy-approved"); + assert(approvalEvents.length === 2, "each effort policy approval is retained in the run event log"); + assert(approvalEvents.at(-1).revision === 2, "the event log identifies the approved policy revision"); + assert( + approvalEvents.at(-1).selectionDigest === explicit.data.effortPolicy.assignments.implementation.approval.selectionDigest, + "the event log binds the operator approval to the selected policy digest", + ); + assert(runNode(cewpCli, ["policy", "set", "full-authority"], repoRoot).status === 0, "fixture grants dispatch authority"); + + const executed = parseJson(runNode(cewpCli, [ + "supervise", "execute", runId, "--yes", "--timeout", "20", "--json", + ], repoRoot, { + env: { + ...fake.env, + CEWP_FAKE_CODEX_EXPECT_MODEL: "gpt-test-explicit", + CEWP_FAKE_CODEX_EXPECT_EFFORT: "high", + }, + }), "execute with approved effort policy"); + const attempt = executed.data.run.tasks[0].attempts[0]; + assert(attempt.codex.taskClass === "demanding-implementation", "dispatch evidence retains the approved task class"); + assert(attempt.codex.effectiveModel.status === "known", "explicit dispatch model becomes known evidence"); + assert(attempt.codex.effectiveModel.value === "gpt-test-explicit", "effective model matches the approved override"); + assert(attempt.codex.effectiveEffort.status === "known", "explicit dispatch effort becomes known evidence"); + assert(attempt.codex.effectiveEffort.value === "high", "effective effort matches the approved override"); + + const verified = parseJson(runNode(cewpCli, [ + "supervise", "verify", runId, "--timeout", "20", "--json", + ], repoRoot), "verify explicit-effort checkpoint"); + assert(verified.data.run.status === "checkpoint-complete", "review setup retains the verification gate"); + parseJson(runNode(cewpCli, [ + "supervise", "effort", runId, + "--operation", "reviewer", + "--task-class", "high-effort-independent-review", + "--model", "gpt-test-reviewer", + "--effort", "xhigh", + "--yes", "--json", + ], repoRoot), "approve reviewer effort policy"); + const reviewed = parseJson(runNode(cewpCli, [ + "supervise", "review", runId, "--yes", "--timeout", "20", "--json", + ], repoRoot, { + env: { + ...fake.env, + CEWP_FAKE_CODEX_EXPECT_MODEL: "gpt-test-reviewer", + CEWP_FAKE_CODEX_EXPECT_EFFORT: "xhigh", + }, + }), "review with approved effort policy"); + assert(reviewed.data.run.reviewer.codex.taskClass === "high-effort-independent-review", "review evidence retains its task class"); + assert(reviewed.data.run.reviewer.codex.effectiveModel.value === "gpt-test-reviewer", "review evidence records the effective model"); + assert(reviewed.data.run.reviewer.codex.effectiveEffort.value === "xhigh", "review evidence records the effective effort"); + } finally { + fs.rmSync(fake.fakeRoot, { recursive: true, force: true }); + cleanupRepo(repoRoot); + } +} + +try { + main(); + runRepairEffortContract(); + runEffortTamperContract(); + runStalePlanRevisionContract(); + console.log("[PASS] Codex task classes never trigger automatic model routing"); +} catch (error) { + console.error("[FAIL] Codex effort policy contract"); + console.error(error && error.stack ? error.stack : error); + process.exitCode = 1; +} diff --git a/tests/contracts/integration-hook-evidence.js b/tests/contracts/integration-hook-evidence.js new file mode 100644 index 0000000..72c05d6 --- /dev/null +++ b/tests/contracts/integration-hook-evidence.js @@ -0,0 +1,192 @@ +"use strict"; + +const fs = require("node:fs"); +const childProcess = require("node:child_process"); +const path = require("node:path"); +const { assert } = require("../harness/lib/assertions"); +const { cleanupRepo, makeTempRepo, runNode } = require("../harness/lib/temp-repo"); +const { validDefinition } = require("./workflow-definition"); +const { approveWorkflow } = require("./workflow-scheduler"); +const { recordSubagentHookEvent } = require("../../src/integration/hook-evidence"); + +const cewpCli = path.join(__dirname, "..", "..", "bin", "cewp.js"); + +function assertThrows(action, expected, label) { + let error; + try { + action(); + } catch (caught) { + error = caught; + } + assert(error, `${label}: expected an error`); + assert(expected.test(error.message), `${label}: unexpected error: ${error.message}`); +} + +function main() { + const repoRoot = makeTempRepo("cewp-hook-evidence-"); + try { + const run = approveWorkflow(repoRoot, validDefinition()); + const runPath = path.join(repoRoot, ".cewp", "workflow-runs", run.runId, "run.json"); + const runBefore = fs.readFileSync(runPath, "utf8"); + const refused = runNode(cewpCli, [ + "integration", "hooks", "approve", run.runId, "--json", + ], repoRoot, { + env: { ...process.env, CEWP_HOOK_CODEX_VERSION: "codex-cli 0.200.0" }, + }); + assert(refused.status === 1, "hook trust cannot be activated without explicit --yes approval"); + assert(refused.stderr.includes("explicit operator approval"), "approval refusal explains the trust boundary"); + const approved = runNode(cewpCli, [ + "integration", "hooks", "approve", run.runId, "--yes", "--json", + ], repoRoot, { + env: { ...process.env, CEWP_HOOK_CODEX_VERSION: "codex-cli 0.200.0" }, + }); + assert(approved.status === 0, `hook approval succeeds: ${approved.stderr}`); + const output = JSON.parse(approved.stdout); + assert(output.command === "integration.hooks.approve", "approval identifies the public command"); + assert(output.data.trust.schemaVersion === "codex-hook-trust/v1", "hook trust is versioned"); + assert(output.data.trust.cewpVersion === "0.10.0-beta.0", "approval binds the CEWP runtime version"); + assert(output.data.trust.codexVersion === "codex-cli 0.200.0", "approval binds the observed Codex version"); + assert(/^sha256:[a-f0-9]{64}$/.test(output.data.trust.bundleDigest), "approval binds the exact hook bundle"); + assert(output.data.nextAction.command === "/hooks", "approval still requires the host trust review"); + assert(fs.readFileSync(runPath, "utf8") === runBefore, "hook trust stays outside provider-neutral run state"); + + const inspected = runNode(cewpCli, [ + "integration", "hooks", "status", run.runId, "--json", + ], repoRoot, { + env: { ...process.env, CEWP_HOOK_CODEX_VERSION: "codex-cli 0.200.0" }, + }); + assert(inspected.status === 0, `hook status succeeds: ${inspected.stderr}`); + const status = JSON.parse(inspected.stdout); + assert(status.command === "integration.hooks.status", "status identifies the public inspection command"); + assert(status.data.compatible === true && status.data.active === true, "current approved hook evidence is active"); + assert(status.data.claims.coreEnforcement === false, "status never promotes hook evidence to Core enforcement"); + + const driftStatus = runNode(cewpCli, [ + "integration", "hooks", "status", run.runId, "--json", + ], repoRoot, { + env: { ...process.env, CEWP_HOOK_CODEX_VERSION: "codex-cli 0.201.0" }, + }); + assert(driftStatus.status === 0, `hook drift status remains inspectable: ${driftStatus.stderr}`); + const drift = JSON.parse(driftStatus.stdout).data; + assert(drift.compatible === false && drift.active === false, "version drift disables trusted hook evidence"); + assert(drift.warnings[0].code === "codex-version-drift", "version drift has a stable compatibility code"); + assert(drift.fallback === "core-and-conversation-only", "version drift names the safe fallback"); + + const hookPath = path.join(__dirname, "..", "..", "plugins", "cewp", "hooks", "capture-subagent.js"); + const runHook = (input) => childProcess.spawnSync(process.execPath, [hookPath], { + cwd: repoRoot, + input: JSON.stringify(input), + encoding: "utf8", + windowsHide: true, + env: { + ...process.env, + CEWP_HOOK_CODEX_VERSION: "codex-cli 0.200.0", + CEWP_HOOK_CLI_COMMAND: process.execPath, + CEWP_HOOK_CLI_PREFIX_ARGS: JSON.stringify([cewpCli]), + }, + }); + const common = { + session_id: "parent-session-1", + transcript_path: path.join(repoRoot, "does-not-exist.jsonl"), + cwd: repoRoot, + model: "gpt-test-host", + turn_id: "parent-turn-1", + agent_id: "agent-1", + agent_type: "explorer", + permission_mode: "default", + }; + const started = runHook({ ...common, hook_event_name: "SubagentStart" }); + assert(started.status === 0, `SubagentStart hook succeeds: ${started.stderr}`); + assert(JSON.stringify(JSON.parse(started.stdout)) === "{}", "evidence-only start does not steer the subagent"); + const stopped = runHook({ + ...common, + hook_event_name: "SubagentStop", + agent_transcript_path: path.join(repoRoot, "also-does-not-exist.jsonl"), + stop_hook_active: false, + last_assistant_message: "Inspected the bounded files and found no scope issue.", + }); + assert(stopped.status === 0, `SubagentStop hook succeeds: ${stopped.stderr}`); + assert(JSON.stringify(JSON.parse(stopped.stdout)) === "{}", "evidence-only stop does not continue or block the subagent"); + + const ledgerPath = path.join( + repoRoot, ".cewp", "workflow-runs", run.runId, "integration", "subagent-hook-evidence.jsonl", + ); + const evidence = fs.readFileSync(ledgerPath, "utf8").trim().split("\n").map((line) => JSON.parse(line)); + assert(evidence.length === 2, "start and stop lifecycle evidence are append-only"); + assert(evidence[0].type === "subagent-started" && evidence[1].type === "subagent-stopped", "supported lifecycle types are normalized"); + assert(evidence[1].references.agentId === "agent-1", "documented subagent id is preserved"); + assert(evidence[1].references.parentSessionId === "parent-session-1", "documented parent session is preserved"); + assert(evidence[1].references.agentThreadId.status === "unknown", "an unavailable subagent thread id is never invented"); + assert(evidence[1].summary.value.includes("no scope issue"), "bounded host summary is retained"); + assert(evidence[1].claims.coreEnforcement === false, "hook evidence never claims Core enforcement"); + + const ledgerBeforeDrift = fs.readFileSync(ledgerPath, "utf8"); + const versionDrift = childProcess.spawnSync(process.execPath, [hookPath], { + cwd: repoRoot, + input: JSON.stringify({ ...common, hook_event_name: "SubagentStart", agent_id: "agent-drift" }), + encoding: "utf8", + windowsHide: true, + env: { + ...process.env, + CEWP_HOOK_CODEX_VERSION: "codex-cli 0.201.0", + CEWP_HOOK_CLI_COMMAND: process.execPath, + CEWP_HOOK_CLI_PREFIX_ARGS: JSON.stringify([cewpCli]), + }, + }); + assert(versionDrift.status === 0, "version drift does not break the host lifecycle"); + assert(JSON.parse(versionDrift.stdout).systemMessage.includes("version drift"), "version drift is visible and actionable"); + assert(fs.readFileSync(ledgerPath, "utf8") === ledgerBeforeDrift, "version drift cannot append trusted evidence"); + + const malformed = runHook({ ...common, hook_event_name: "SubagentStart", agent_id: null }); + assert(malformed.status === 0, "malformed hook input fails without breaking the host lifecycle"); + assert(JSON.parse(malformed.stdout).systemMessage.includes("agent_id is required"), "malformed input explains the compatibility failure"); + assert(fs.readFileSync(ledgerPath, "utf8") === ledgerBeforeDrift, "malformed input cannot append evidence"); + + const changedPluginRoot = path.join(repoRoot, "changed-plugin"); + fs.mkdirSync(path.join(changedPluginRoot, "hooks"), { recursive: true }); + fs.copyFileSync( + path.join(__dirname, "..", "..", "plugins", "cewp", "hooks", "hooks.json"), + path.join(changedPluginRoot, "hooks", "hooks.json"), + ); + fs.copyFileSync(hookPath, path.join(changedPluginRoot, "hooks", "capture-subagent.js")); + fs.appendFileSync(path.join(changedPluginRoot, "hooks", "capture-subagent.js"), "\n// changed after review\n"); + assertThrows( + () => recordSubagentHookEvent({ + repoRoot, + input: { ...common, hook_event_name: "SubagentStart", agent_id: "agent-definition-drift" }, + codexVersion: "codex-cli 0.200.0", + pluginRoot: changedPluginRoot, + }), + /definition drift/, + "changed hook definitions require a fresh review", + ); + assert(fs.readFileSync(ledgerPath, "utf8") === ledgerBeforeDrift, "definition drift cannot append trusted evidence"); + assert(fs.readFileSync(runPath, "utf8") === runBefore, "hook failures and observations never mutate Core workflow state"); + + const trustPath = path.join( + repoRoot, ".cewp", "workflow-runs", run.runId, "integration", "codex-hook-trust.json", + ); + fs.writeFileSync(trustPath, "{ malformed trust receipt\n"); + const malformedTrustStatus = runNode(cewpCli, [ + "integration", "hooks", "status", run.runId, "--json", + ], repoRoot, { + env: { ...process.env, CEWP_HOOK_CODEX_VERSION: "codex-cli 0.200.0" }, + }); + assert(malformedTrustStatus.status === 0, "malformed trust remains inspectable through a fail-safe status"); + const malformedTrust = JSON.parse(malformedTrustStatus.stdout).data; + assert(malformedTrust.active === false && malformedTrust.compatible === false, "malformed trust disables evidence"); + assert(malformedTrust.warnings[0].code === "hook-trust-change", "malformed trust has a stable warning code"); + assert(malformedTrust.fallback === "core-and-conversation-only", "malformed trust preserves the safe fallback"); + } finally { + cleanupRepo(repoRoot); + } +} + +try { + main(); + console.log("[PASS] hook evidence is explicitly approved and version-bound"); +} catch (error) { + console.error("[FAIL] hook evidence integration contract"); + console.error(error && error.stack ? error.stack : error); + process.exitCode = 1; +} diff --git a/tests/contracts/plugin-package.js b/tests/contracts/plugin-package.js index a2a4e1e..b1f63e6 100644 --- a/tests/contracts/plugin-package.js +++ b/tests/contracts/plugin-package.js @@ -24,7 +24,16 @@ function runPluginPackageContract() { assert(manifest.skills === "./skills/", "plugin skill path is contained and relative"); assert(manifest.apps === undefined, "plugin does not claim an unbuilt app"); assert(manifest.mcpServers === undefined, "plugin does not claim an unbuilt MCP server"); - assert(manifest.hooks === undefined, "plugin does not enable unreviewed hooks"); + assert(manifest.hooks === "./hooks/hooks.json", "plugin declares one contained reviewable hook bundle"); + const hookConfig = readJson(path.join(repoRoot, "plugins", "cewp", "hooks", "hooks.json")); + assert( + JSON.stringify(Object.keys(hookConfig.hooks).sort()) === JSON.stringify(["SubagentStart", "SubagentStop"]), + "plugin hooks are limited to subagent evidence events", + ); + assert( + fs.existsSync(path.join(repoRoot, "plugins", "cewp", "hooks", "capture-subagent.js")), + "declared hook handler exists", + ); assert( fs.existsSync(path.join(repoRoot, "plugins", "cewp", "assets", "cewp.svg")), "plugin asset exists", diff --git a/tests/contracts/supervised-execution.js b/tests/contracts/supervised-execution.js index cc8ad5b..bd7d198 100644 --- a/tests/contracts/supervised-execution.js +++ b/tests/contracts/supervised-execution.js @@ -48,6 +48,57 @@ function createApprovedRun(repoRoot) { return runId; } +function runNativeOwnershipConflictContract() { + const repoRoot = makeTempRepo("cewp-supervised-native-conflict-"); + const fake = createFakeCodexAdapter(); + try { + const runId = createApprovedRun(repoRoot); + assert(runNode(cewpCli, ["policy", "set", "full-authority"], repoRoot).status === 0, "fixture grants worker authority"); + const runPath = path.join(repoRoot, ".cewp", "supervised-runs", runId, "run.json"); + const run = JSON.parse(fs.readFileSync(runPath, "utf8")); + const taskId = run.tasks[0].id; + const targetWorktree = path.resolve( + repoRoot, + "..", + ".cewp-worktrees", + path.basename(repoRoot), + runId, + taskId, + ); + const nativeOwnershipPath = path.join( + repoRoot, + ".cewp", + "workflow-runs", + "native-owner", + "integration", + "ownership.json", + ); + fs.mkdirSync(path.dirname(nativeOwnershipPath), { recursive: true }); + fs.writeFileSync(nativeOwnershipPath, `${JSON.stringify({ + schemaVersion: "execution-ownership/v1", + runId: "native-owner", + taskId, + checkpointId: `${taskId}-attempt-0001`, + owner: "native", + backend: null, + status: "active", + createdAt: "2026-07-18T12:00:00.000Z", + cleanupAuthority: "host-owner", + worktree: { id: `${runId}:${taskId}`, path: targetWorktree }, + }, null, 2)}\n`); + + const result = runNode(cewpCli, [ + "supervise", "execute", runId, "--yes", "--timeout", "20", "--json", + ], repoRoot, { env: fake.env }); + assert(result.status === 1, "managed dispatch rejects a native-owned task worktree"); + assert(result.stderr.includes("execution-ownership-conflict"), "ownership refusal is actionable"); + assert(!fs.existsSync(targetWorktree), "conflicting managed worktree is not created"); + } finally { + fs.rmSync(fake.fakeRoot, { recursive: true, force: true }); + cleanupRepo(repoRoot); + } +} + function runSupervisedExecutionContract() { const repoRoot = makeTempRepo("cewp-supervised-exec-"); const fake = createFakeCodexAdapter(); @@ -209,6 +260,7 @@ function runSupervisedExecutionContract() { } try { + runNativeOwnershipConflictContract(); runSupervisedExecutionContract(); console.log("[PASS] supervised dispatch preserves ownership, scope, and usage truth"); } catch (error) { diff --git a/tests/harness/README.md b/tests/harness/README.md index cc20b65..945ed1c 100644 --- a/tests/harness/README.md +++ b/tests/harness/README.md @@ -25,6 +25,7 @@ npm run test:skill-format npm run test:ownership-gates npm run test:fixtures npm run test:plugin-package +npm run test:integration-hook-evidence ``` `npm test` runs these focused contracts before the broader smoke lifecycle. diff --git a/tests/harness/lib/fake-adapter.js b/tests/harness/lib/fake-adapter.js index 53438f2..bc528ac 100644 --- a/tests/harness/lib/fake-adapter.js +++ b/tests/harness/lib/fake-adapter.js @@ -38,6 +38,17 @@ if (args[0] !== "exec") { process.exit(2); } +const expectedModel = process.env.CEWP_FAKE_CODEX_EXPECT_MODEL; +const expectedEffort = process.env.CEWP_FAKE_CODEX_EXPECT_EFFORT; +if (expectedModel && valueAfter("--model") !== expectedModel) { + console.error("fake codex did not receive the approved --model override"); + process.exit(3); +} +if (expectedEffort && !args.includes('model_reasoning_effort="' + expectedEffort + '"')) { + console.error("fake codex did not receive the approved reasoning-effort override"); + process.exit(3); +} + function valueAfter(flag) { const index = args.indexOf(flag); return index === -1 ? undefined : args[index + 1];