From 3341598d0cbee16b2267c6595038c46c43d83f4b Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:39:09 +0000 Subject: [PATCH 1/3] feat(shell): sync agent plans to pull requests --- .../core/templates-entrypoint/git-hooks.ts | 56 +++++++++++++++- packages/app/src/lib/core/templates.ts | 2 + .../lib/core/templates/dockerfile-prelude.ts | 25 +++++-- .../tests/docker-git/core-templates.test.ts | 20 ++++++ .../core/templates-entrypoint/git-hooks.ts | 56 +++++++++++++++- packages/lib/src/core/templates.ts | 2 + .../src/core/templates/dockerfile-prelude.ts | 25 +++++-- .../tests/core/git-post-push-wrapper.test.ts | 66 ++++++++++++++++++- packages/lib/tests/core/templates.test.ts | 38 ++++++++++- 9 files changed, 277 insertions(+), 13 deletions(-) diff --git a/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts b/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts index 4ba009ff..197751dc 100644 --- a/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts +++ b/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts @@ -5,6 +5,8 @@ const entrypointGitHooksTemplate = String HOOKS_DIR="/opt/docker-git/hooks" PRE_PUSH_HOOK="$HOOKS_DIR/pre-push" POST_PUSH_ACTION="$HOOKS_DIR/post-push" +PLAN_TO_GIT_CODEX_HOOK="$HOOKS_DIR/plan-to-git-codex-hook" +CODEX_REQUIREMENTS_FILE="/etc/codex/requirements.toml" mkdir -p "$HOOKS_DIR" cat <<'EOF' > "$PRE_PUSH_HOOK" @@ -136,13 +138,24 @@ cat <<'EOF' > "$POST_PUSH_ACTION" #!/usr/bin/env bash set -euo pipefail -# 5) Run session backup after successful push +# 5) Run plan sync and session backup after successful push REPO_ROOT="${"${"}DOCKER_GIT_POST_PUSH_REPO_ROOT:-}" if [[ -z "$REPO_ROOT" || ! -d "$REPO_ROOT" ]]; then REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" fi cd "$REPO_ROOT" +# CHANGE: sync captured Codex plans to the current branch PR after push. +# WHY: issue #369 requires the agent plan to be uploaded to PR discussion. +# REF: issue-369 +if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" != "1" ]; then + if ! command -v plan-to-git >/dev/null 2>&1; then + echo "[plan-to-git] Error: plan-to-git not found" >&2 + exit 1 + fi + plan-to-git sync +fi + # CHANGE: keep post-push backup logic in a reusable action script # WHY: git has no client-side post-push hook, so the global git wrapper # invokes this after a successful git push @@ -161,6 +174,47 @@ fi EOF chmod 0755 "$POST_PUSH_ACTION" +cat <<'EOF' > "$PLAN_TO_GIT_CODEX_HOOK" +#!/usr/bin/env bash +set -euo pipefail + +if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" = "1" ]; then + exit 0 +fi + +if ! command -v plan-to-git >/dev/null 2>&1; then + echo "[plan-to-git] Error: plan-to-git not found" >&2 + exit 1 +fi + +plan-to-git hook --source codex +EOF +chmod 0755 "$PLAN_TO_GIT_CODEX_HOOK" + +mkdir -p "$(dirname "$CODEX_REQUIREMENTS_FILE")" +cat <<'EOF' > "$CODEX_REQUIREMENTS_FILE" +# docker-git managed Codex requirements + +[features] +hooks = true + +[hooks] +managed_dir = "/opt/docker-git/hooks" + +[[hooks.UserPromptSubmit]] +[[hooks.UserPromptSubmit.hooks]] +type = "command" +command = "/opt/docker-git/hooks/plan-to-git-codex-hook" +statusMessage = "Capturing plan decision" + +[[hooks.Stop]] +[[hooks.Stop.hooks]] +type = "command" +command = "/opt/docker-git/hooks/plan-to-git-codex-hook" +statusMessage = "Capturing agent plan" +EOF +chmod 0644 "$CODEX_REQUIREMENTS_FILE" + ${renderEntrypointGitPostPushWrapperInstall()} git config --system core.hooksPath "$HOOKS_DIR" || true diff --git a/packages/app/src/lib/core/templates.ts b/packages/app/src/lib/core/templates.ts index 475f07bf..2a5b26fd 100644 --- a/packages/app/src/lib/core/templates.ts +++ b/packages/app/src/lib/core/templates.ts @@ -39,6 +39,7 @@ scripts/ # Volatile Codex artifacts (do not commit) authorized_keys +.agent-plan.json .orch/auth/codex/auth.json .orch/auth/claude/ .orch/auth/codex/log/ @@ -50,6 +51,7 @@ authorized_keys const renderDockerignore = (): string => `# docker-git build context authorized_keys +.agent-plan.json .orch/env/ .orch/auth/codex/ .orch/auth/claude/ diff --git a/packages/app/src/lib/core/templates/dockerfile-prelude.ts b/packages/app/src/lib/core/templates/dockerfile-prelude.ts index 6880432a..66872a5b 100644 --- a/packages/app/src/lib/core/templates/dockerfile-prelude.ts +++ b/packages/app/src/lib/core/templates/dockerfile-prelude.ts @@ -83,16 +83,33 @@ RUN cargo install --git https://github.com/ProverCoderAI/rust-browser-connection RUN printf "%s\\n" "ALL ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/zz-all \ && chmod 0440 /etc/sudoers.d/zz-all` +const planToGitRevision = "06fe8bdf1d2e48a1f5a0218a3bb7af19e63deb5e" + +// CHANGE: install plan-to-git in generated project containers. +// WHY: issue #369 requires agent plans to be captured and uploaded to pull requests. +// QUOTE(ТЗ): "Надо что бы у нас план загружался в PR" +// REF: issue-369 +// SOURCE: https://github.com/ProverCoderAI/plan-to-git/tree/v0.19.0 +// FORMAT THEOREM: image_build_success -> executable(/usr/local/bin/plan-to-git) +// PURITY: SHELL +// EFFECT: Docker build downloads and installs a pinned Rust CLI from GitHub. +// INVARIANT: plan-to-git is available on PATH before Codex hooks or git post-push actions run. +// COMPLEXITY: O(network + cargo_build) +const renderDockerfilePlanToGit = (): string => + `# Install plan-to-git for Codex plan capture and PR sync (issue #369) +RUN cargo install --git https://github.com/ProverCoderAI/plan-to-git --rev ${planToGitRevision} --locked --bins --root /usr/local \ + && /usr/local/bin/plan-to-git --help >/dev/null` + /** - * Renders the base image, package prelude, Rust toolchain, and browser module install. + * Renders the base image, package prelude, Rust toolchain, browser module, and plan sync CLI install. * * @returns Dockerfile fragment that establishes the shared project container base. * @pure true * @effect none; CORE template renderer only constructs a string. - * @invariant the returned fragment starts from the configured shared JS box image and installs the Rust browser lifecycle + MCP CLIs. + * @invariant the returned fragment starts from the configured shared JS box image and installs the Rust browser lifecycle, MCP CLIs, and plan-to-git. * @precondition docker-git generated entrypoint remains the container entrypoint. - * @postcondition the fragment keeps root available for setup and publishes both Rust browser binaries on PATH. + * @postcondition the fragment keeps root available for setup and publishes Rust helper binaries on PATH. * @complexity O(1) time / O(1) space. */ export const renderDockerfilePrelude = (): string => - [renderDockerfileBase(), renderDockerfileRustBrowserConnection()].join("\n\n") + [renderDockerfileBase(), renderDockerfileRustBrowserConnection(), renderDockerfilePlanToGit()].join("\n\n") diff --git a/packages/app/tests/docker-git/core-templates.test.ts b/packages/app/tests/docker-git/core-templates.test.ts index 904b036f..83ffcd7b 100644 --- a/packages/app/tests/docker-git/core-templates.test.ts +++ b/packages/app/tests/docker-git/core-templates.test.ts @@ -63,6 +63,10 @@ describe("app planFiles", () => { expect(dockerfile.contents).toContain( "cargo install --git https://github.com/ProverCoderAI/rust-browser-connection" ) + expect(dockerfile.contents).toContain( + "cargo install --git https://github.com/ProverCoderAI/plan-to-git --rev 06fe8bdf1d2e48a1f5a0218a3bb7af19e63deb5e --locked --bins --root /usr/local" + ) + expect(dockerfile.contents).toContain("/usr/local/bin/plan-to-git --help >/dev/null") expect(dockerfile.contents).toContain("make build-essential docker.io") expect(dockerfile.contents).toContain("/usr/local/bin/browser-connection --version") expect(dockerfile.contents).not.toContain("docker-git-playwright-mcp") @@ -76,5 +80,21 @@ describe("app planFiles", () => { expect(entrypoint.contents).toContain( "args = [\"--project\", \"$DOCKER_GIT_BROWSER_PROJECT\", \"--network\", \"$DOCKER_GIT_BROWSER_NETWORK\"]" ) + expect(entrypoint.contents).toContain("plan-to-git sync") + expect(entrypoint.contents).toContain("plan-to-git hook --source codex") + expect(entrypoint.contents).toContain("CODEX_REQUIREMENTS_FILE=\"/etc/codex/requirements.toml\"") + expect(entrypoint.contents).toContain("managed_dir = \"/opt/docker-git/hooks\"") + expect(entrypoint.contents).toContain("[[hooks.UserPromptSubmit]]") + expect(entrypoint.contents).toContain("[[hooks.Stop]]") + expect(entrypoint.contents).toContain("command = \"/opt/docker-git/hooks/plan-to-git-codex-hook\"") + }) + + it("keeps plan-to-git state out of generated git and docker contexts", () => { + const files = planFiles(makeTemplateConfig()) + const gitignore = getGeneratedFile(files, ".gitignore") + const dockerignore = getGeneratedFile(files, ".dockerignore") + + expect(gitignore.contents).toContain(".agent-plan.json") + expect(dockerignore.contents).toContain(".agent-plan.json") }) }) diff --git a/packages/lib/src/core/templates-entrypoint/git-hooks.ts b/packages/lib/src/core/templates-entrypoint/git-hooks.ts index 4ba009ff..197751dc 100644 --- a/packages/lib/src/core/templates-entrypoint/git-hooks.ts +++ b/packages/lib/src/core/templates-entrypoint/git-hooks.ts @@ -5,6 +5,8 @@ const entrypointGitHooksTemplate = String HOOKS_DIR="/opt/docker-git/hooks" PRE_PUSH_HOOK="$HOOKS_DIR/pre-push" POST_PUSH_ACTION="$HOOKS_DIR/post-push" +PLAN_TO_GIT_CODEX_HOOK="$HOOKS_DIR/plan-to-git-codex-hook" +CODEX_REQUIREMENTS_FILE="/etc/codex/requirements.toml" mkdir -p "$HOOKS_DIR" cat <<'EOF' > "$PRE_PUSH_HOOK" @@ -136,13 +138,24 @@ cat <<'EOF' > "$POST_PUSH_ACTION" #!/usr/bin/env bash set -euo pipefail -# 5) Run session backup after successful push +# 5) Run plan sync and session backup after successful push REPO_ROOT="${"${"}DOCKER_GIT_POST_PUSH_REPO_ROOT:-}" if [[ -z "$REPO_ROOT" || ! -d "$REPO_ROOT" ]]; then REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" fi cd "$REPO_ROOT" +# CHANGE: sync captured Codex plans to the current branch PR after push. +# WHY: issue #369 requires the agent plan to be uploaded to PR discussion. +# REF: issue-369 +if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" != "1" ]; then + if ! command -v plan-to-git >/dev/null 2>&1; then + echo "[plan-to-git] Error: plan-to-git not found" >&2 + exit 1 + fi + plan-to-git sync +fi + # CHANGE: keep post-push backup logic in a reusable action script # WHY: git has no client-side post-push hook, so the global git wrapper # invokes this after a successful git push @@ -161,6 +174,47 @@ fi EOF chmod 0755 "$POST_PUSH_ACTION" +cat <<'EOF' > "$PLAN_TO_GIT_CODEX_HOOK" +#!/usr/bin/env bash +set -euo pipefail + +if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" = "1" ]; then + exit 0 +fi + +if ! command -v plan-to-git >/dev/null 2>&1; then + echo "[plan-to-git] Error: plan-to-git not found" >&2 + exit 1 +fi + +plan-to-git hook --source codex +EOF +chmod 0755 "$PLAN_TO_GIT_CODEX_HOOK" + +mkdir -p "$(dirname "$CODEX_REQUIREMENTS_FILE")" +cat <<'EOF' > "$CODEX_REQUIREMENTS_FILE" +# docker-git managed Codex requirements + +[features] +hooks = true + +[hooks] +managed_dir = "/opt/docker-git/hooks" + +[[hooks.UserPromptSubmit]] +[[hooks.UserPromptSubmit.hooks]] +type = "command" +command = "/opt/docker-git/hooks/plan-to-git-codex-hook" +statusMessage = "Capturing plan decision" + +[[hooks.Stop]] +[[hooks.Stop.hooks]] +type = "command" +command = "/opt/docker-git/hooks/plan-to-git-codex-hook" +statusMessage = "Capturing agent plan" +EOF +chmod 0644 "$CODEX_REQUIREMENTS_FILE" + ${renderEntrypointGitPostPushWrapperInstall()} git config --system core.hooksPath "$HOOKS_DIR" || true diff --git a/packages/lib/src/core/templates.ts b/packages/lib/src/core/templates.ts index 0a53822a..099f2d62 100644 --- a/packages/lib/src/core/templates.ts +++ b/packages/lib/src/core/templates.ts @@ -38,6 +38,7 @@ scripts/ # Volatile Codex artifacts (do not commit) authorized_keys +.agent-plan.json .orch/auth/codex/auth.json .orch/auth/claude/ .orch/auth/codex/log/ @@ -49,6 +50,7 @@ authorized_keys const renderDockerignore = (): string => `# docker-git build context authorized_keys +.agent-plan.json .orch/env/ .orch/auth/codex/ .orch/auth/claude/ diff --git a/packages/lib/src/core/templates/dockerfile-prelude.ts b/packages/lib/src/core/templates/dockerfile-prelude.ts index 6880432a..66872a5b 100644 --- a/packages/lib/src/core/templates/dockerfile-prelude.ts +++ b/packages/lib/src/core/templates/dockerfile-prelude.ts @@ -83,16 +83,33 @@ RUN cargo install --git https://github.com/ProverCoderAI/rust-browser-connection RUN printf "%s\\n" "ALL ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/zz-all \ && chmod 0440 /etc/sudoers.d/zz-all` +const planToGitRevision = "06fe8bdf1d2e48a1f5a0218a3bb7af19e63deb5e" + +// CHANGE: install plan-to-git in generated project containers. +// WHY: issue #369 requires agent plans to be captured and uploaded to pull requests. +// QUOTE(ТЗ): "Надо что бы у нас план загружался в PR" +// REF: issue-369 +// SOURCE: https://github.com/ProverCoderAI/plan-to-git/tree/v0.19.0 +// FORMAT THEOREM: image_build_success -> executable(/usr/local/bin/plan-to-git) +// PURITY: SHELL +// EFFECT: Docker build downloads and installs a pinned Rust CLI from GitHub. +// INVARIANT: plan-to-git is available on PATH before Codex hooks or git post-push actions run. +// COMPLEXITY: O(network + cargo_build) +const renderDockerfilePlanToGit = (): string => + `# Install plan-to-git for Codex plan capture and PR sync (issue #369) +RUN cargo install --git https://github.com/ProverCoderAI/plan-to-git --rev ${planToGitRevision} --locked --bins --root /usr/local \ + && /usr/local/bin/plan-to-git --help >/dev/null` + /** - * Renders the base image, package prelude, Rust toolchain, and browser module install. + * Renders the base image, package prelude, Rust toolchain, browser module, and plan sync CLI install. * * @returns Dockerfile fragment that establishes the shared project container base. * @pure true * @effect none; CORE template renderer only constructs a string. - * @invariant the returned fragment starts from the configured shared JS box image and installs the Rust browser lifecycle + MCP CLIs. + * @invariant the returned fragment starts from the configured shared JS box image and installs the Rust browser lifecycle, MCP CLIs, and plan-to-git. * @precondition docker-git generated entrypoint remains the container entrypoint. - * @postcondition the fragment keeps root available for setup and publishes both Rust browser binaries on PATH. + * @postcondition the fragment keeps root available for setup and publishes Rust helper binaries on PATH. * @complexity O(1) time / O(1) space. */ export const renderDockerfilePrelude = (): string => - [renderDockerfileBase(), renderDockerfileRustBrowserConnection()].join("\n\n") + [renderDockerfileBase(), renderDockerfileRustBrowserConnection(), renderDockerfilePlanToGit()].join("\n\n") diff --git a/packages/lib/tests/core/git-post-push-wrapper.test.ts b/packages/lib/tests/core/git-post-push-wrapper.test.ts index cacdf7a2..7fb184b7 100644 --- a/packages/lib/tests/core/git-post-push-wrapper.test.ts +++ b/packages/lib/tests/core/git-post-push-wrapper.test.ts @@ -25,6 +25,7 @@ type WrapperHarness = { readonly nodeCwdLogPath: string readonly nodeRepoRootLogPath: string readonly nodeScriptLogPath: string + readonly planToGitLogPath: string } const fakeGitScript = `#!/usr/bin/env bash @@ -109,6 +110,20 @@ set -euo pipefail exit 0 ` +const fakePlanToGitScript = `#!/usr/bin/env bash +set -euo pipefail + +if [[ -n "\${FAKE_PLAN_TO_GIT_LOG_PATH:-}" ]]; then + printf '%s\\t%s\\n' "$PWD" "$*" >> "$FAKE_PLAN_TO_GIT_LOG_PATH" +fi + +if [[ -n "\${FAKE_PLAN_TO_GIT_EXIT_CODE:-}" ]]; then + exit "$FAKE_PLAN_TO_GIT_EXIT_CODE" +fi + +exit 0 +` + const collectUint8Array = (chunks: Chunk.Chunk): Uint8Array => Chunk.reduce(chunks, new Uint8Array(), (acc, curr) => { const next = new Uint8Array(acc.length + curr.length) @@ -203,6 +218,7 @@ const makeHarnessEnv = ( FAKE_NODE_CWD_LOG_PATH: harness.nodeCwdLogPath, FAKE_NODE_REPO_ROOT_LOG_PATH: harness.nodeRepoRootLogPath, FAKE_NODE_SCRIPT_LOG_PATH: harness.nodeScriptLogPath, + FAKE_PLAN_TO_GIT_LOG_PATH: harness.planToGitLogPath, ...overrides }) @@ -244,6 +260,7 @@ const withHarness = ( const nodeCwdLogPath = path.join(rootDir, "node-cwd.log") const nodeRepoRootLogPath = path.join(rootDir, "node-repo-root.log") const nodeScriptLogPath = path.join(rootDir, "node-script.log") + const planToGitLogPath = path.join(rootDir, "plan-to-git.log") yield* _(fs.makeDirectory(path.join(repoDir, ".git"), { recursive: true })) yield* _(fs.makeDirectory(externalDir, { recursive: true })) @@ -254,6 +271,7 @@ const withHarness = ( yield* _(writeExecutable(path.join(binDir, "git-real"), fakeGitScript)) yield* _(writeExecutable(path.join(binDir, "gh"), fakeGhScript)) yield* _(writeExecutable(path.join(binDir, "docker-git-session-sync"), fakeSessionSyncScript)) + yield* _(writeExecutable(path.join(binDir, "plan-to-git"), fakePlanToGitScript)) const postPushScript = extractEmbeddedScript(renderEntrypointGitHooks(), "$POST_PUSH_ACTION") const postPushPath = path.join(hooksDir, "post-push") @@ -278,7 +296,8 @@ const withHarness = ( gitLogPath, nodeCwdLogPath, nodeRepoRootLogPath, - nodeScriptLogPath + nodeScriptLogPath, + planToGitLogPath }) ) }) @@ -293,10 +312,12 @@ describe("git post-push wrapper", () => { const nodeCwd = yield* _(readLogLines(harness.nodeCwdLogPath)) const nodeRepoRoot = yield* _(readLogLines(harness.nodeRepoRootLogPath)) const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) + const planToGit = yield* _(readLogLines(harness.planToGitLogPath)) expect(nodeCwd).toEqual([harness.repoDir]) expect(nodeRepoRoot).toEqual([harness.repoDir]) expect(nodeScript).toEqual(["backup --verbose --background --require-comment"]) + expect(planToGit).toEqual([`${harness.repoDir}\tsync`]) }) ).pipe(Effect.provide(NodeContext.layer))) @@ -309,10 +330,12 @@ describe("git post-push wrapper", () => { const nodeRepoRoot = yield* _(readLogLines(harness.nodeRepoRootLogPath)) const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) const gitLog = yield* _(readLogLines(harness.gitLogPath)) + const planToGit = yield* _(readLogLines(harness.planToGitLogPath)) expect(nodeCwd).toEqual([harness.repoDir]) expect(nodeRepoRoot).toEqual([harness.repoDir]) expect(nodeScript).toEqual(["backup --verbose --background --require-comment"]) + expect(planToGit).toEqual([`${harness.repoDir}\tsync`]) expect(gitLog.some((line) => line.startsWith(`${harness.externalDir}\t-C ${harness.repoDir} push`))).toBe(true) }) ).pipe(Effect.provide(NodeContext.layer))) @@ -326,10 +349,12 @@ describe("git post-push wrapper", () => { const nodeCwd = yield* _(readLogLines(harness.nodeCwdLogPath)) const nodeRepoRoot = yield* _(readLogLines(harness.nodeRepoRootLogPath)) const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) + const planToGit = yield* _(readLogLines(harness.planToGitLogPath)) expect(nodeCwd).toEqual([]) expect(nodeRepoRoot).toEqual([]) expect(nodeScript).toEqual([]) + expect(planToGit).toEqual([]) }) ).pipe(Effect.provide(NodeContext.layer))) @@ -351,10 +376,47 @@ describe("git post-push wrapper", () => { const nodeCwd = yield* _(readLogLines(harness.nodeCwdLogPath)) const nodeRepoRoot = yield* _(readLogLines(harness.nodeRepoRootLogPath)) const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) + const planToGit = yield* _(readLogLines(harness.planToGitLogPath)) expect(nodeCwd).toEqual([]) expect(nodeRepoRoot).toEqual([]) expect(nodeScript).toEqual([]) + expect(planToGit).toEqual([]) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("skips plan sync when disabled but still runs session backup", () => + withHarness((harness) => + Effect.gen(function*(_) { + yield* _( + runWrapper(harness, harness.repoDir, ["push", "origin", "HEAD"], { + env: { DOCKER_GIT_SKIP_PLAN_TO_GIT: "1" } + }) + ) + + const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) + const planToGit = yield* _(readLogLines(harness.planToGitLogPath)) + + expect(nodeScript).toEqual(["backup --verbose --background --require-comment"]) + expect(planToGit).toEqual([]) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("propagates plan sync failures before session backup", () => + withHarness((harness) => + Effect.gen(function*(_) { + yield* _( + runWrapper(harness, harness.repoDir, ["push", "origin", "HEAD"], { + env: { FAKE_PLAN_TO_GIT_EXIT_CODE: "37" }, + okExitCodes: [37] + }) + ) + + const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) + const planToGit = yield* _(readLogLines(harness.planToGitLogPath)) + + expect(nodeScript).toEqual([]) + expect(planToGit).toEqual([`${harness.repoDir}\tsync`]) }) ).pipe(Effect.provide(NodeContext.layer))) @@ -369,8 +431,10 @@ describe("git post-push wrapper", () => { ) const nodeScript = yield* _(readLogLines(harness.nodeScriptLogPath)) + const planToGit = yield* _(readLogLines(harness.planToGitLogPath)) expect(nodeScript).toEqual(["backup --verbose --background --require-comment"]) + expect(planToGit).toEqual([`${harness.repoDir}\tsync`]) }) ).pipe(Effect.provide(NodeContext.layer))) }) diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 7e62e32f..9b0ef951 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -207,6 +207,9 @@ describe("renderDockerfile", () => { 'RTK_VERSION="${RTK_VERSION}" RTK_INSTALL_DIR=/usr/local/bin sh /tmp/rtk-install.sh', "rtk --version", "rtk gain >/dev/null 2>&1 || true", + "# Install plan-to-git for Codex plan capture and PR sync (issue #369)", + "cargo install --git https://github.com/ProverCoderAI/plan-to-git --rev 06fe8bdf1d2e48a1f5a0218a3bb7af19e63deb5e --locked --bins --root /usr/local", + "/usr/local/bin/plan-to-git --help >/dev/null", 'ARG DOCKER_GIT_SESSION_SYNC_PACKAGE="@prover-coder-ai/docker-git-session-sync@latest"', 'COPY .docker-git-tools/docker-git-session-sync /opt/docker-git/tools/docker-git-session-sync', 'npm install -g "$DOCKER_GIT_SESSION_SYNC_PACKAGE"', @@ -461,18 +464,32 @@ describe("renderEntrypoint clone cache", () => { }) describe("renderEntrypointGitHooks", () => { - it("installs pre-push protection checks and a global git post-push runtime", () => { + it("installs pre-push protection checks, plan sync, and a global git post-push runtime", () => { const hooks = renderEntrypointGitHooks() expect(hooks).toContain('PRE_PUSH_HOOK="$HOOKS_DIR/pre-push"') expect(hooks).toContain('POST_PUSH_ACTION="$HOOKS_DIR/post-push"') + expect(hooks).toContain('PLAN_TO_GIT_CODEX_HOOK="$HOOKS_DIR/plan-to-git-codex-hook"') + expect(hooks).toContain('CODEX_REQUIREMENTS_FILE="/etc/codex/requirements.toml"') expect(hooks).toContain('GIT_WRAPPER_BIN="/usr/local/bin/git"') expect(hooks).toContain('type -aP git') expect(hooks).toContain("cat <<'EOF' > \"$PRE_PUSH_HOOK\"") expect(hooks).toContain("cat <<'EOF' > \"$POST_PUSH_ACTION\"") + expect(hooks).toContain("cat <<'EOF' > \"$PLAN_TO_GIT_CODEX_HOOK\"") + expect(hooks).toContain("cat <<'EOF' > \"$CODEX_REQUIREMENTS_FILE\"") expect(hooks).toContain("cat <<'EOF' > \"$GIT_WRAPPER_BIN\"") expect(hooks).toContain("check_issue_managed_block_range") - expect(hooks).toContain("Run session backup after successful push") + expect(hooks).toContain("Run plan sync and session backup after successful push") + expect(hooks).toContain("DOCKER_GIT_SKIP_PLAN_TO_GIT") + expect(hooks).toContain("plan-to-git sync") + expect(hooks).toContain("plan-to-git hook --source codex") + expect(hooks).toContain("[features]") + expect(hooks).toContain("hooks = true") + expect(hooks).toContain('managed_dir = "/opt/docker-git/hooks"') + expect(hooks).toContain("[[hooks.UserPromptSubmit]]") + expect(hooks).toContain("[[hooks.Stop]]") + expect(hooks).toContain('command = "/opt/docker-git/hooks/plan-to-git-codex-hook"') + expect(hooks).not.toContain("allow_managed_hooks_only") expect(hooks).toContain("git has no client-side post-push hook") expect(hooks).toContain("docker-git managed git wrapper") expect(hooks).toContain("DOCKER_GIT_SKIP_POST_PUSH_ACTION=1") @@ -493,6 +510,23 @@ describe("renderEntrypointGitHooks", () => { }) }) +describe("planFiles generated ignores", () => { + it("keeps plan-to-git state out of git and docker build contexts", () => { + const files = planFiles(makeTemplateConfig()) + const gitignore = files.find( + (file): file is Extract<(typeof files)[number], { readonly _tag: "File" }> => + file._tag === "File" && file.relativePath === ".gitignore" + ) + const dockerignore = files.find( + (file): file is Extract<(typeof files)[number], { readonly _tag: "File" }> => + file._tag === "File" && file.relativePath === ".dockerignore" + ) + + expect(gitignore?.contents).toContain(".agent-plan.json") + expect(dockerignore?.contents).toContain(".agent-plan.json") + }) +}) + describe("renderEntrypoint auth bridge", () => { const renderAuthEntrypoint = (): string => renderEntrypoint( From ac2aa6ab7fa0d0d05dcc78e18b311f139c800349 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:58:40 +0000 Subject: [PATCH 2/3] test(shell): strengthen plan-to-git review coverage --- .../tests/docker-git/core-templates.test.ts | 111 ++++++++++++------ .../tests/core/git-post-push-wrapper.test.ts | 8 ++ packages/lib/tests/core/templates.test.ts | 26 ++-- 3 files changed, 96 insertions(+), 49 deletions(-) diff --git a/packages/app/tests/docker-git/core-templates.test.ts b/packages/app/tests/docker-git/core-templates.test.ts index 83ffcd7b..9d9ab295 100644 --- a/packages/app/tests/docker-git/core-templates.test.ts +++ b/packages/app/tests/docker-git/core-templates.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest" +import * as fc from "fast-check" import { defaultTemplateConfig, planFiles, type TemplateConfig } from "../../test-adapters/core-templates.js" @@ -41,6 +42,32 @@ const getGeneratedFile = (files: ReadonlyArray, relativePath: strin const getGeneratedFilePaths = (files: ReadonlyArray): ReadonlyArray => files.flatMap((file) => file._tag === "File" ? [file.relativePath] : []) +const generatedTemplateConfigArbitrary: fc.Arbitrary = fc + .record({ + gpu: fc.constantFrom("none", "all"), + projectIndex: fc.integer({ min: 1, max: 100_000 }), + sshPort: fc.integer({ min: 1024, max: 65_535 }), + sshUserIndex: fc.integer({ min: 1, max: 100_000 }) + }) + .map(({ gpu, projectIndex, sshPort, sshUserIndex }) => { + const sshUser = `dev${sshUserIndex}` + const projectName = `repo-${projectIndex}` + const home = `/home/${sshUser}` + + return makeTemplateConfig({ + codexHome: `${home}/.codex`, + containerName: `dg-test-${projectIndex}`, + geminiHome: `${home}/.gemini`, + gpu, + grokHome: `${home}/.grok`, + serviceName: `dg-test-${projectIndex}`, + sshPort, + sshUser, + targetDir: `${home}/org/${projectName}`, + volumeName: `dg-test-${projectIndex}-home` + }) + }) + describe("app planFiles", () => { it("includes Grok auth bootstrap wiring in the generated entrypoint", () => { const files = planFiles(makeTemplateConfig()) @@ -52,49 +79,57 @@ describe("app planFiles", () => { }) it("uses the Rust browser connection module when Playwright is enabled", () => { - const files = planFiles(makeTemplateConfig({ enableMcpPlaywright: true })) - const filePaths = getGeneratedFilePaths(files) - const dockerfile = getGeneratedFile(files, "Dockerfile") - const entrypoint = getGeneratedFile(files, "entrypoint.sh") + fc.assert( + fc.property(generatedTemplateConfigArbitrary, (generatedConfig) => { + const files = planFiles({ ...generatedConfig, enableMcpPlaywright: true }) + const filePaths = getGeneratedFilePaths(files) + const dockerfile = getGeneratedFile(files, "Dockerfile") + const entrypoint = getGeneratedFile(files, "entrypoint.sh") - expect(filePaths).not.toContain("Dockerfile.browser") - expect(filePaths).not.toContain("docker-git-cdp-guard") - expect(filePaths).not.toContain("docker-git-browser-runtime.sh") - expect(dockerfile.contents).toContain( - "cargo install --git https://github.com/ProverCoderAI/rust-browser-connection" + expect(filePaths).not.toContain("Dockerfile.browser") + expect(filePaths).not.toContain("docker-git-cdp-guard") + expect(filePaths).not.toContain("docker-git-browser-runtime.sh") + expect(dockerfile.contents).toContain( + "cargo install --git https://github.com/ProverCoderAI/rust-browser-connection" + ) + expect(dockerfile.contents).toContain( + "cargo install --git https://github.com/ProverCoderAI/plan-to-git --rev 06fe8bdf1d2e48a1f5a0218a3bb7af19e63deb5e --locked --bins --root /usr/local" + ) + expect(dockerfile.contents).toContain("/usr/local/bin/plan-to-git --help >/dev/null") + expect(dockerfile.contents).toContain("make build-essential docker.io") + expect(dockerfile.contents).toContain("/usr/local/bin/browser-connection --version") + expect(dockerfile.contents).not.toContain("docker-git-playwright-mcp") + expect(entrypoint.contents).not.toContain("docker_git_start_rust_browser_connection") + expect(entrypoint.contents).not.toContain("start --project") + expect(entrypoint.contents).not.toContain("--no-start-browser") + expect(entrypoint.contents).toContain("docker_git_stop_playwright_browser()") + expect(entrypoint.contents).toContain("docker-git-browser-connection") + expect(entrypoint.contents).toContain("stop --project \"$project_container\"") + expect(entrypoint.contents).toContain("command = \"browser-connection\"") + expect(entrypoint.contents).toContain( + "args = [\"--project\", \"$DOCKER_GIT_BROWSER_PROJECT\", \"--network\", \"$DOCKER_GIT_BROWSER_NETWORK\"]" + ) + expect(entrypoint.contents).toContain("plan-to-git sync") + expect(entrypoint.contents).toContain("plan-to-git hook --source codex") + expect(entrypoint.contents).toContain("CODEX_REQUIREMENTS_FILE=\"/etc/codex/requirements.toml\"") + expect(entrypoint.contents).toContain("managed_dir = \"/opt/docker-git/hooks\"") + expect(entrypoint.contents).toContain("[[hooks.UserPromptSubmit]]") + expect(entrypoint.contents).toContain("[[hooks.Stop]]") + expect(entrypoint.contents).toContain("command = \"/opt/docker-git/hooks/plan-to-git-codex-hook\"") + }) ) - expect(dockerfile.contents).toContain( - "cargo install --git https://github.com/ProverCoderAI/plan-to-git --rev 06fe8bdf1d2e48a1f5a0218a3bb7af19e63deb5e --locked --bins --root /usr/local" - ) - expect(dockerfile.contents).toContain("/usr/local/bin/plan-to-git --help >/dev/null") - expect(dockerfile.contents).toContain("make build-essential docker.io") - expect(dockerfile.contents).toContain("/usr/local/bin/browser-connection --version") - expect(dockerfile.contents).not.toContain("docker-git-playwright-mcp") - expect(entrypoint.contents).not.toContain("docker_git_start_rust_browser_connection") - expect(entrypoint.contents).not.toContain("start --project") - expect(entrypoint.contents).not.toContain("--no-start-browser") - expect(entrypoint.contents).toContain("docker_git_stop_playwright_browser()") - expect(entrypoint.contents).toContain("docker-git-browser-connection") - expect(entrypoint.contents).toContain("stop --project \"$project_container\"") - expect(entrypoint.contents).toContain("command = \"browser-connection\"") - expect(entrypoint.contents).toContain( - "args = [\"--project\", \"$DOCKER_GIT_BROWSER_PROJECT\", \"--network\", \"$DOCKER_GIT_BROWSER_NETWORK\"]" - ) - expect(entrypoint.contents).toContain("plan-to-git sync") - expect(entrypoint.contents).toContain("plan-to-git hook --source codex") - expect(entrypoint.contents).toContain("CODEX_REQUIREMENTS_FILE=\"/etc/codex/requirements.toml\"") - expect(entrypoint.contents).toContain("managed_dir = \"/opt/docker-git/hooks\"") - expect(entrypoint.contents).toContain("[[hooks.UserPromptSubmit]]") - expect(entrypoint.contents).toContain("[[hooks.Stop]]") - expect(entrypoint.contents).toContain("command = \"/opt/docker-git/hooks/plan-to-git-codex-hook\"") }) it("keeps plan-to-git state out of generated git and docker contexts", () => { - const files = planFiles(makeTemplateConfig()) - const gitignore = getGeneratedFile(files, ".gitignore") - const dockerignore = getGeneratedFile(files, ".dockerignore") + fc.assert( + fc.property(generatedTemplateConfigArbitrary, (config) => { + const files = planFiles(config) + const gitignore = getGeneratedFile(files, ".gitignore") + const dockerignore = getGeneratedFile(files, ".dockerignore") - expect(gitignore.contents).toContain(".agent-plan.json") - expect(dockerignore.contents).toContain(".agent-plan.json") + expect(gitignore.contents).toContain(".agent-plan.json") + expect(dockerignore.contents).toContain(".agent-plan.json") + }) + ) }) }) diff --git a/packages/lib/tests/core/git-post-push-wrapper.test.ts b/packages/lib/tests/core/git-post-push-wrapper.test.ts index 7fb184b7..487e5fd5 100644 --- a/packages/lib/tests/core/git-post-push-wrapper.test.ts +++ b/packages/lib/tests/core/git-post-push-wrapper.test.ts @@ -117,6 +117,14 @@ if [[ -n "\${FAKE_PLAN_TO_GIT_LOG_PATH:-}" ]]; then printf '%s\\t%s\\n' "$PWD" "$*" >> "$FAKE_PLAN_TO_GIT_LOG_PATH" fi +if [[ "\${1:-}" != "sync" ]]; then + if [[ -n "\${FAKE_PLAN_TO_GIT_LOG_PATH:-}" ]]; then + printf '%s\\tunexpected-command:%s\\n' "$PWD" "\${1:-}" >> "$FAKE_PLAN_TO_GIT_LOG_PATH" + fi + echo "fakePlanToGit: expected sync command, got: \${1:-}" >&2 + exit 127 +fi + if [[ -n "\${FAKE_PLAN_TO_GIT_EXIT_CODE:-}" ]]; then exit "$FAKE_PLAN_TO_GIT_EXIT_CODE" fi diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 9b0ef951..a167f97a 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -512,18 +512,22 @@ describe("renderEntrypointGitHooks", () => { describe("planFiles generated ignores", () => { it("keeps plan-to-git state out of git and docker build contexts", () => { - const files = planFiles(makeTemplateConfig()) - const gitignore = files.find( - (file): file is Extract<(typeof files)[number], { readonly _tag: "File" }> => - file._tag === "File" && file.relativePath === ".gitignore" - ) - const dockerignore = files.find( - (file): file is Extract<(typeof files)[number], { readonly _tag: "File" }> => - file._tag === "File" && file.relativePath === ".dockerignore" + fc.assert( + fc.property(generatedTemplateConfigArbitrary, (config) => { + const files = planFiles(config) + const gitignore = files.find( + (file): file is Extract<(typeof files)[number], { readonly _tag: "File" }> => + file._tag === "File" && file.relativePath === ".gitignore" + ) + const dockerignore = files.find( + (file): file is Extract<(typeof files)[number], { readonly _tag: "File" }> => + file._tag === "File" && file.relativePath === ".dockerignore" + ) + + expect(gitignore?.contents).toContain(".agent-plan.json") + expect(dockerignore?.contents).toContain(".agent-plan.json") + }) ) - - expect(gitignore?.contents).toContain(".agent-plan.json") - expect(dockerignore?.contents).toContain(".agent-plan.json") }) }) From d0b50a044c671b3bbdcbf8c96bcf124aa59b4c26 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:16:56 +0000 Subject: [PATCH 3/3] chore(git): ignore plan-to-git state --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e27fe0ce..fb60caac 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ dev_ssh_key.pub .docker-git/ .e2e/ effect-template1/ +.agent-plan.json # Node / build artifacts node_modules/