From 8a14af1dc1b2f1de881fff679edbc3117bc69b77 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:00:35 +0000 Subject: [PATCH 1/2] feat(runtime): wire plan-to-git claude hooks and pr sync --- .changeset/plan-to-git-claude-hooks.md | 6 + .../core/templates-entrypoint/git-hooks.ts | 167 +++++++++++++++++- .../lib/core/templates/dockerfile-prelude.ts | 18 +- .../tests/docker-git/core-templates.test.ts | 22 ++- .../core/templates-entrypoint/git-hooks.ts | 167 +++++++++++++++++- .../src/core/templates/dockerfile-prelude.ts | 18 +- .../tests/core/git-post-push-wrapper.test.ts | 8 +- packages/lib/tests/core/templates.test.ts | 33 +++- 8 files changed, 404 insertions(+), 35 deletions(-) create mode 100644 .changeset/plan-to-git-claude-hooks.md diff --git a/.changeset/plan-to-git-claude-hooks.md b/.changeset/plan-to-git-claude-hooks.md new file mode 100644 index 00000000..1ef0d5f9 --- /dev/null +++ b/.changeset/plan-to-git-claude-hooks.md @@ -0,0 +1,6 @@ +--- +"@effect-template/lib": patch +"@prover-coder-ai/docker-git": patch +--- + +Connect the generated project containers to the new multi-agent plan-to-git build, install Claude Code plan hooks, and route queued agent plans through explicit PR-aware sync. 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 a862cd48..77c69d88 100644 --- a/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts +++ b/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts @@ -6,8 +6,11 @@ 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_SYNC_HELPER="$HOOKS_DIR/plan-to-git-sync" PLAN_TO_GIT_CODEX_HOOK="$HOOKS_DIR/plan-to-git-codex-hook" +PLAN_TO_GIT_CLAUDE_HOOK="$HOOKS_DIR/plan-to-git-claude-hook" CODEX_REQUIREMENTS_FILE="/etc/codex/requirements.toml" +CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="$CLAUDE_CONFIG_DIR/settings.json" mkdir -p "$HOOKS_DIR" cat <<'EOF' > "$PRE_PUSH_HOOK" @@ -135,6 +138,75 @@ done EOF chmod 0755 "$PRE_PUSH_HOOK" +cat <<'EOF' > "$PLAN_TO_GIT_SYNC_HELPER" +#!/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 + +export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" + +docker_git_plan_to_git_explicit_pr_supported() { + plan-to-git sync --help 2>/dev/null | grep -q -- "--pr " +} + +docker_git_plan_to_git_resolve_pr_number() { + local candidate="" + local key="" + for key in DOCKER_GIT_PR_NUMBER PR_NUMBER GITHUB_PR_NUMBER; do + candidate="${"${"}!key:-}" + if [[ "$candidate" =~ ^[0-9]+$ ]]; then + printf "%s\n" "$candidate" + return 0 + fi + done + + candidate="${"${"}REPO_REF:-}" + if [[ "$candidate" =~ ^refs/pull/([0-9]+)/head$ ]]; then + printf "%s\n" "${"${"}BASH_REMATCH[1]}" + return 0 + fi + if [[ "$candidate" =~ ^pull/([0-9]+)$ ]]; then + printf "%s\n" "${"${"}BASH_REMATCH[1]}" + return 0 + fi + + if command -v gh >/dev/null 2>&1; then + candidate="$(gh pr view --json number --jq .number 2>/dev/null || true)" + if [[ "$candidate" =~ ^[0-9]+$ ]]; then + printf "%s\n" "$candidate" + return 0 + fi + fi + + return 0 +} + +docker_git_plan_to_git_sync() { + local pr_number="" + pr_number="$(docker_git_plan_to_git_resolve_pr_number || true)" + + if [[ -n "$pr_number" ]] && docker_git_plan_to_git_explicit_pr_supported; then + echo "[plan-to-git] Syncing queued agent plans to PR #$pr_number" + plan-to-git sync --pr "$pr_number" + return 0 + fi + + echo "[plan-to-git] Syncing queued agent plans via current branch discovery" + plan-to-git sync +} + +docker_git_plan_to_git_sync +EOF +chmod 0755 "$PLAN_TO_GIT_SYNC_HELPER" + cat <<'EOF' > "$POST_PUSH_ACTION" #!/usr/bin/env bash set -euo pipefail @@ -148,16 +220,24 @@ cd "$REPO_ROOT" ${renderPostPushPrEnsure()} -# CHANGE: backfill Codex session plans before syncing the current branch PR. -# WHY: live Codex hooks can be unavailable in already-running sessions; session logs are the durable fallback. -# REF: issue-375 +# CHANGE: backfill agent session plans before syncing the current branch or explicit PR. +# WHY: live agent hooks can be unavailable in already-running sessions; session logs are the durable fallback. +# QUOTE(ТЗ): "что бы всё уходило на гитхаб автоматически" +# REF: issue-397 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 import-codex --no-sync - plan-to-git sync + plan-to-git import-claude --no-sync + PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" + if [[ -x "$PLAN_TO_GIT_SYNC_HELPER" ]]; then + "$PLAN_TO_GIT_SYNC_HELPER" + else + echo "[plan-to-git] Sync helper not found; falling back to current branch discovery" >&2 + plan-to-git sync + fi fi # CHANGE: keep post-push backup logic in a reusable action script @@ -191,10 +271,33 @@ if ! command -v plan-to-git >/dev/null 2>&1; then exit 1 fi +export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" plan-to-git hook --source codex +PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" +"$PLAN_TO_GIT_SYNC_HELPER" >&2 || true EOF chmod 0755 "$PLAN_TO_GIT_CODEX_HOOK" +cat <<'EOF' > "$PLAN_TO_GIT_CLAUDE_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 + +export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" +plan-to-git hook --source claude +PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" +"$PLAN_TO_GIT_SYNC_HELPER" >&2 || true +EOF +chmod 0755 "$PLAN_TO_GIT_CLAUDE_HOOK" + mkdir -p "$(dirname "$CODEX_REQUIREMENTS_FILE")" cat <<'EOF' > "$CODEX_REQUIREMENTS_FILE" # docker-git managed Codex requirements @@ -219,6 +322,62 @@ statusMessage = "Capturing agent plan" EOF chmod 0644 "$CODEX_REQUIREMENTS_FILE" +docker_git_install_claude_plan_to_git_hooks() { + if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" = "1" ]; then + return 0 + fi + + CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="${"${"}CLAUDE_PLAN_TO_GIT_SETTINGS_FILE:-${"${"}CLAUDE_CONFIG_DIR:-/home/dev/.claude}/settings.json}" + CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" PLAN_TO_GIT_CLAUDE_HOOK="$PLAN_TO_GIT_CLAUDE_HOOK" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") + +const settingsPath = process.env.CLAUDE_PLAN_TO_GIT_SETTINGS_FILE +const hookCommand = process.env.PLAN_TO_GIT_CLAUDE_HOOK || "/opt/docker-git/hooks/plan-to-git-claude-hook" +if (typeof settingsPath !== "string" || settingsPath.length === 0) { + process.exit(0) +} + +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) + +let settings = {} +try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) + settings = isRecord(parsed) ? parsed : {} +} catch { + settings = {} +} + +const currentHooks = isRecord(settings.hooks) ? settings.hooks : {} +const nextHooks = { ...currentHooks } +const managedHook = { type: "command", command: hookCommand } +const ensureEventHook = (eventName) => { + const currentEventHooks = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] + const alreadyInstalled = currentEventHooks.some((entry) => + isRecord(entry) && + Array.isArray(entry.hooks) && + entry.hooks.some((hook) => isRecord(hook) && hook.type === "command" && hook.command === hookCommand) + ) + nextHooks[eventName] = alreadyInstalled ? currentEventHooks : [...currentEventHooks, { hooks: [managedHook] }] +} + +ensureEventHook("UserPromptSubmit") +ensureEventHook("Stop") + +const nextSettings = { ...settings, hooks: nextHooks } +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) { + process.exit(0) +} + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE + chmod 0600 "$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" 2>/dev/null || true + chown 1000:1000 "$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" 2>/dev/null || true +} + +docker_git_install_claude_plan_to_git_hooks + ${renderEntrypointGitPostPushWrapperInstall()} git config --system core.hooksPath "$HOOKS_DIR" || true diff --git a/packages/app/src/lib/core/templates/dockerfile-prelude.ts b/packages/app/src/lib/core/templates/dockerfile-prelude.ts index 66872a5b..d1f9d42d 100644 --- a/packages/app/src/lib/core/templates/dockerfile-prelude.ts +++ b/packages/app/src/lib/core/templates/dockerfile-prelude.ts @@ -83,22 +83,24 @@ 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" +const planToGitRevision = "f60fbe71131854be4c6c1d9fb79abafd2dd6949b" // 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 +// WHY: issue #397 requires multi-agent plan capture, Claude Code hooks, temp-backed state, and explicit PR sync. +// QUOTE(ТЗ): "подключение новое версии plan-to-git и настройки hooks для claude code и настройки что бы всё уходило на гитхаб автоматически" +// REF: issue-397 +// SOURCE: https://github.com/ProverCoderAI/plan-to-git/tree/f60fbe71131854be4c6c1d9fb79abafd2dd6949b // 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. +// INVARIANT: plan-to-git is available on PATH with Claude hooks and sync --pr before agent 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) + `# Install plan-to-git for multi-agent plan capture and explicit PR sync (issue #397) 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` + && /usr/local/bin/plan-to-git --help >/dev/null \ + && /usr/local/bin/plan-to-git hook --help | grep -q -- "claude" \ + && /usr/local/bin/plan-to-git sync --help | grep -q -- "--pr "` /** * Renders the base image, package prelude, Rust toolchain, browser module, and plan sync CLI install. diff --git a/packages/app/tests/docker-git/core-templates.test.ts b/packages/app/tests/docker-git/core-templates.test.ts index 95fcf98c..fe9508ff 100644 --- a/packages/app/tests/docker-git/core-templates.test.ts +++ b/packages/app/tests/docker-git/core-templates.test.ts @@ -93,9 +93,11 @@ describe("app planFiles", () => { "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" + "cargo install --git https://github.com/ProverCoderAI/plan-to-git --rev f60fbe71131854be4c6c1d9fb79abafd2dd6949b --locked --bins --root /usr/local" ) expect(dockerfile.contents).toContain("/usr/local/bin/plan-to-git --help >/dev/null") + expect(dockerfile.contents).toContain("/usr/local/bin/plan-to-git hook --help | grep -q -- \"claude\"") + expect(dockerfile.contents).toContain("/usr/local/bin/plan-to-git sync --help | grep -q -- \"--pr \"") 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") @@ -110,14 +112,24 @@ describe("app planFiles", () => { "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_SYNC_HELPER=\"$HOOKS_DIR/plan-to-git-sync\"") + expect(entrypoint.contents).toContain("plan-to-git sync --pr \"$pr_number\"") expect(entrypoint.contents).toContain("docker_git_ensure_open_pr") expect(entrypoint.contents).toContain("gh pr list --repo \"$base_repo\" --state open --head \"$head_arg\"") expect(entrypoint.contents).toContain( "gh pr create --repo \"$base_repo\" --base \"$base_branch\" --head \"$head_arg\" --fill" ) expect(entrypoint.contents).toContain("plan-to-git hook --source codex") + expect(entrypoint.contents).toContain("plan-to-git hook --source claude") expect(entrypoint.contents).toContain("plan-to-git import-codex --no-sync") + expect(entrypoint.contents).toContain("plan-to-git import-claude --no-sync") expect(entrypoint.contents).toContain("CODEX_REQUIREMENTS_FILE=\"/etc/codex/requirements.toml\"") + expect(entrypoint.contents).toContain("PLAN_TO_GIT_CLAUDE_HOOK=\"$HOOKS_DIR/plan-to-git-claude-hook\"") + expect(entrypoint.contents).toContain("docker_git_install_claude_plan_to_git_hooks") + expect(entrypoint.contents).toContain("CLAUDE_PLAN_TO_GIT_SETTINGS_FILE=\"$CLAUDE_CONFIG_DIR/settings.json\"") + expect(entrypoint.contents).toContain("ensureEventHook(\"UserPromptSubmit\")") + expect(entrypoint.contents).toContain("ensureEventHook(\"Stop\")") + expect(entrypoint.contents).toContain("PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git") expect(entrypoint.contents).toContain("managed_dir = \"/opt/docker-git/hooks\"") expect(entrypoint.contents).toContain("[[hooks.UserPromptSubmit]]") expect(entrypoint.contents).toContain("[[hooks.Stop]]") @@ -125,10 +137,11 @@ describe("app planFiles", () => { const cdIndex = entrypoint.contents.indexOf("cd \"$REPO_ROOT\"") const ensurePrIndex = entrypoint.contents.indexOf( - "docker_git_ensure_open_pr\n\n# CHANGE: backfill Codex session plans" + "docker_git_ensure_open_pr\n\n# CHANGE: backfill agent session plans" ) const planImportIndex = entrypoint.contents.indexOf("plan-to-git import-codex --no-sync") - const planSyncIndex = entrypoint.contents.indexOf("plan-to-git sync") + const claudeImportIndex = entrypoint.contents.indexOf("plan-to-git import-claude --no-sync") + const planSyncIndex = entrypoint.contents.indexOf("\"$PLAN_TO_GIT_SYNC_HELPER\"", claudeImportIndex) const sessionBackupIndex = entrypoint.contents.indexOf( "docker-git-session-sync backup --verbose --background --require-comment" ) @@ -136,7 +149,8 @@ describe("app planFiles", () => { expect(cdIndex).toBeGreaterThanOrEqual(0) expect(ensurePrIndex).toBeGreaterThan(cdIndex) expect(planImportIndex).toBeGreaterThan(ensurePrIndex) - expect(planSyncIndex).toBeGreaterThan(planImportIndex) + expect(claudeImportIndex).toBeGreaterThan(planImportIndex) + expect(planSyncIndex).toBeGreaterThan(claudeImportIndex) expect(sessionBackupIndex).toBeGreaterThan(planSyncIndex) }) ) diff --git a/packages/lib/src/core/templates-entrypoint/git-hooks.ts b/packages/lib/src/core/templates-entrypoint/git-hooks.ts index a862cd48..77c69d88 100644 --- a/packages/lib/src/core/templates-entrypoint/git-hooks.ts +++ b/packages/lib/src/core/templates-entrypoint/git-hooks.ts @@ -6,8 +6,11 @@ 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_SYNC_HELPER="$HOOKS_DIR/plan-to-git-sync" PLAN_TO_GIT_CODEX_HOOK="$HOOKS_DIR/plan-to-git-codex-hook" +PLAN_TO_GIT_CLAUDE_HOOK="$HOOKS_DIR/plan-to-git-claude-hook" CODEX_REQUIREMENTS_FILE="/etc/codex/requirements.toml" +CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="$CLAUDE_CONFIG_DIR/settings.json" mkdir -p "$HOOKS_DIR" cat <<'EOF' > "$PRE_PUSH_HOOK" @@ -135,6 +138,75 @@ done EOF chmod 0755 "$PRE_PUSH_HOOK" +cat <<'EOF' > "$PLAN_TO_GIT_SYNC_HELPER" +#!/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 + +export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" + +docker_git_plan_to_git_explicit_pr_supported() { + plan-to-git sync --help 2>/dev/null | grep -q -- "--pr " +} + +docker_git_plan_to_git_resolve_pr_number() { + local candidate="" + local key="" + for key in DOCKER_GIT_PR_NUMBER PR_NUMBER GITHUB_PR_NUMBER; do + candidate="${"${"}!key:-}" + if [[ "$candidate" =~ ^[0-9]+$ ]]; then + printf "%s\n" "$candidate" + return 0 + fi + done + + candidate="${"${"}REPO_REF:-}" + if [[ "$candidate" =~ ^refs/pull/([0-9]+)/head$ ]]; then + printf "%s\n" "${"${"}BASH_REMATCH[1]}" + return 0 + fi + if [[ "$candidate" =~ ^pull/([0-9]+)$ ]]; then + printf "%s\n" "${"${"}BASH_REMATCH[1]}" + return 0 + fi + + if command -v gh >/dev/null 2>&1; then + candidate="$(gh pr view --json number --jq .number 2>/dev/null || true)" + if [[ "$candidate" =~ ^[0-9]+$ ]]; then + printf "%s\n" "$candidate" + return 0 + fi + fi + + return 0 +} + +docker_git_plan_to_git_sync() { + local pr_number="" + pr_number="$(docker_git_plan_to_git_resolve_pr_number || true)" + + if [[ -n "$pr_number" ]] && docker_git_plan_to_git_explicit_pr_supported; then + echo "[plan-to-git] Syncing queued agent plans to PR #$pr_number" + plan-to-git sync --pr "$pr_number" + return 0 + fi + + echo "[plan-to-git] Syncing queued agent plans via current branch discovery" + plan-to-git sync +} + +docker_git_plan_to_git_sync +EOF +chmod 0755 "$PLAN_TO_GIT_SYNC_HELPER" + cat <<'EOF' > "$POST_PUSH_ACTION" #!/usr/bin/env bash set -euo pipefail @@ -148,16 +220,24 @@ cd "$REPO_ROOT" ${renderPostPushPrEnsure()} -# CHANGE: backfill Codex session plans before syncing the current branch PR. -# WHY: live Codex hooks can be unavailable in already-running sessions; session logs are the durable fallback. -# REF: issue-375 +# CHANGE: backfill agent session plans before syncing the current branch or explicit PR. +# WHY: live agent hooks can be unavailable in already-running sessions; session logs are the durable fallback. +# QUOTE(ТЗ): "что бы всё уходило на гитхаб автоматически" +# REF: issue-397 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 import-codex --no-sync - plan-to-git sync + plan-to-git import-claude --no-sync + PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" + if [[ -x "$PLAN_TO_GIT_SYNC_HELPER" ]]; then + "$PLAN_TO_GIT_SYNC_HELPER" + else + echo "[plan-to-git] Sync helper not found; falling back to current branch discovery" >&2 + plan-to-git sync + fi fi # CHANGE: keep post-push backup logic in a reusable action script @@ -191,10 +271,33 @@ if ! command -v plan-to-git >/dev/null 2>&1; then exit 1 fi +export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" plan-to-git hook --source codex +PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" +"$PLAN_TO_GIT_SYNC_HELPER" >&2 || true EOF chmod 0755 "$PLAN_TO_GIT_CODEX_HOOK" +cat <<'EOF' > "$PLAN_TO_GIT_CLAUDE_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 + +export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" +plan-to-git hook --source claude +PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" +"$PLAN_TO_GIT_SYNC_HELPER" >&2 || true +EOF +chmod 0755 "$PLAN_TO_GIT_CLAUDE_HOOK" + mkdir -p "$(dirname "$CODEX_REQUIREMENTS_FILE")" cat <<'EOF' > "$CODEX_REQUIREMENTS_FILE" # docker-git managed Codex requirements @@ -219,6 +322,62 @@ statusMessage = "Capturing agent plan" EOF chmod 0644 "$CODEX_REQUIREMENTS_FILE" +docker_git_install_claude_plan_to_git_hooks() { + if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" = "1" ]; then + return 0 + fi + + CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="${"${"}CLAUDE_PLAN_TO_GIT_SETTINGS_FILE:-${"${"}CLAUDE_CONFIG_DIR:-/home/dev/.claude}/settings.json}" + CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" PLAN_TO_GIT_CLAUDE_HOOK="$PLAN_TO_GIT_CLAUDE_HOOK" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") + +const settingsPath = process.env.CLAUDE_PLAN_TO_GIT_SETTINGS_FILE +const hookCommand = process.env.PLAN_TO_GIT_CLAUDE_HOOK || "/opt/docker-git/hooks/plan-to-git-claude-hook" +if (typeof settingsPath !== "string" || settingsPath.length === 0) { + process.exit(0) +} + +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) + +let settings = {} +try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) + settings = isRecord(parsed) ? parsed : {} +} catch { + settings = {} +} + +const currentHooks = isRecord(settings.hooks) ? settings.hooks : {} +const nextHooks = { ...currentHooks } +const managedHook = { type: "command", command: hookCommand } +const ensureEventHook = (eventName) => { + const currentEventHooks = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] + const alreadyInstalled = currentEventHooks.some((entry) => + isRecord(entry) && + Array.isArray(entry.hooks) && + entry.hooks.some((hook) => isRecord(hook) && hook.type === "command" && hook.command === hookCommand) + ) + nextHooks[eventName] = alreadyInstalled ? currentEventHooks : [...currentEventHooks, { hooks: [managedHook] }] +} + +ensureEventHook("UserPromptSubmit") +ensureEventHook("Stop") + +const nextSettings = { ...settings, hooks: nextHooks } +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) { + process.exit(0) +} + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE + chmod 0600 "$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" 2>/dev/null || true + chown 1000:1000 "$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" 2>/dev/null || true +} + +docker_git_install_claude_plan_to_git_hooks + ${renderEntrypointGitPostPushWrapperInstall()} git config --system core.hooksPath "$HOOKS_DIR" || true diff --git a/packages/lib/src/core/templates/dockerfile-prelude.ts b/packages/lib/src/core/templates/dockerfile-prelude.ts index 66872a5b..d1f9d42d 100644 --- a/packages/lib/src/core/templates/dockerfile-prelude.ts +++ b/packages/lib/src/core/templates/dockerfile-prelude.ts @@ -83,22 +83,24 @@ 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" +const planToGitRevision = "f60fbe71131854be4c6c1d9fb79abafd2dd6949b" // 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 +// WHY: issue #397 requires multi-agent plan capture, Claude Code hooks, temp-backed state, and explicit PR sync. +// QUOTE(ТЗ): "подключение новое версии plan-to-git и настройки hooks для claude code и настройки что бы всё уходило на гитхаб автоматически" +// REF: issue-397 +// SOURCE: https://github.com/ProverCoderAI/plan-to-git/tree/f60fbe71131854be4c6c1d9fb79abafd2dd6949b // 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. +// INVARIANT: plan-to-git is available on PATH with Claude hooks and sync --pr before agent 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) + `# Install plan-to-git for multi-agent plan capture and explicit PR sync (issue #397) 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` + && /usr/local/bin/plan-to-git --help >/dev/null \ + && /usr/local/bin/plan-to-git hook --help | grep -q -- "claude" \ + && /usr/local/bin/plan-to-git sync --help | grep -q -- "--pr "` /** * Renders the base image, package prelude, Rust toolchain, browser module, and plan sync CLI install. 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 f3dcba41..0b752a54 100644 --- a/packages/lib/tests/core/git-post-push-wrapper.test.ts +++ b/packages/lib/tests/core/git-post-push-wrapper.test.ts @@ -171,11 +171,11 @@ if [[ -n "\${FAKE_PLAN_TO_GIT_LOG_PATH:-}" ]]; then printf '%s\\t%s\\n' "$PWD" "$*" >> "$FAKE_PLAN_TO_GIT_LOG_PATH" fi -if [[ "\${1:-}" != "import-codex" && "\${1:-}" != "sync" ]]; then +if [[ "\${1:-}" != "import-codex" && "\${1:-}" != "import-claude" && "\${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 import-codex or sync command, got: \${1:-}" >&2 + echo "fakePlanToGit: expected import-codex, import-claude, or sync command, got: \${1:-}" >&2 exit 127 fi @@ -385,6 +385,7 @@ describe("git post-push wrapper", () => { expect(nodeScript).toEqual(["backup --verbose --background --require-comment"]) expect(planToGit).toEqual([ `${harness.repoDir}\timport-codex --no-sync`, + `${harness.repoDir}\timport-claude --no-sync`, `${harness.repoDir}\tsync` ]) expect(gh).toContain(`${harness.repoDir}\tpr create --repo org/repo --base main --head issue-375 --fill`) @@ -408,6 +409,7 @@ describe("git post-push wrapper", () => { expect(nodeScript).toEqual(["backup --verbose --background --require-comment"]) expect(planToGit).toEqual([ `${harness.repoDir}\timport-codex --no-sync`, + `${harness.repoDir}\timport-claude --no-sync`, `${harness.repoDir}\tsync` ]) expect(gh).toContain(`${harness.repoDir}\tpr create --repo org/repo --base main --head issue-375 --fill`) @@ -519,6 +521,7 @@ describe("git post-push wrapper", () => { expect(nodeScript).toEqual(["backup --verbose --background --require-comment"]) expect(planToGit).toEqual([ `${harness.repoDir}\timport-codex --no-sync`, + `${harness.repoDir}\timport-claude --no-sync`, `${harness.repoDir}\tsync` ]) }) @@ -540,6 +543,7 @@ describe("git post-push wrapper", () => { expect(nodeScript).toEqual(["backup --verbose --background --require-comment"]) expect(planToGit).toEqual([ `${harness.repoDir}\timport-codex --no-sync`, + `${harness.repoDir}\timport-claude --no-sync`, `${harness.repoDir}\tsync` ]) expect(gh).toContain(`${harness.repoDir}\tpr list --repo org/repo --state open --head issue-375 --json url --jq .[0].url // ""`) diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index ce980124..db4dbf4b 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -209,9 +209,11 @@ 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", + "# Install plan-to-git for multi-agent plan capture and explicit PR sync (issue #397)", + "cargo install --git https://github.com/ProverCoderAI/plan-to-git --rev f60fbe71131854be4c6c1d9fb79abafd2dd6949b --locked --bins --root /usr/local", "/usr/local/bin/plan-to-git --help >/dev/null", + '/usr/local/bin/plan-to-git hook --help | grep -q -- "claude"', + '/usr/local/bin/plan-to-git sync --help | grep -q -- "--pr "', '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"', @@ -475,13 +477,18 @@ describe("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_SYNC_HELPER="$HOOKS_DIR/plan-to-git-sync"') expect(hooks).toContain('PLAN_TO_GIT_CODEX_HOOK="$HOOKS_DIR/plan-to-git-codex-hook"') + expect(hooks).toContain('PLAN_TO_GIT_CLAUDE_HOOK="$HOOKS_DIR/plan-to-git-claude-hook"') expect(hooks).toContain('CODEX_REQUIREMENTS_FILE="/etc/codex/requirements.toml"') + expect(hooks).toContain('CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="$CLAUDE_CONFIG_DIR/settings.json"') 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' > \"$PLAN_TO_GIT_SYNC_HELPER\"") expect(hooks).toContain("cat <<'EOF' > \"$POST_PUSH_ACTION\"") expect(hooks).toContain("cat <<'EOF' > \"$PLAN_TO_GIT_CODEX_HOOK\"") + expect(hooks).toContain("cat <<'EOF' > \"$PLAN_TO_GIT_CLAUDE_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") @@ -494,8 +501,22 @@ describe("renderEntrypointGitHooks", () => { expect(hooks).toContain("[post-push-pr] Error: failed to list open PRs") expect(hooks).toContain("DOCKER_GIT_SKIP_PLAN_TO_GIT") expect(hooks).toContain("plan-to-git import-codex --no-sync") + expect(hooks).toContain("plan-to-git import-claude --no-sync") + expect(hooks).toContain("docker_git_plan_to_git_explicit_pr_supported") + expect(hooks).toContain("docker_git_plan_to_git_resolve_pr_number") + expect(hooks).toContain("DOCKER_GIT_PR_NUMBER PR_NUMBER GITHUB_PR_NUMBER") + expect(hooks).toContain('candidate="${REPO_REF:-}"') + expect(hooks).toContain('plan-to-git sync --pr "$pr_number"') expect(hooks).toContain("plan-to-git sync") + expect(hooks).toContain('[plan-to-git] Syncing queued agent plans to PR #$pr_number') expect(hooks).toContain("plan-to-git hook --source codex") + expect(hooks).toContain("plan-to-git hook --source claude") + expect(hooks).toContain('export PLAN_TO_GIT_STATE_DIR="${PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}"') + expect(hooks).toContain('"$PLAN_TO_GIT_SYNC_HELPER" >&2 || true') + expect(hooks).toContain("docker_git_install_claude_plan_to_git_hooks") + expect(hooks).toContain('const hookCommand = process.env.PLAN_TO_GIT_CLAUDE_HOOK || "/opt/docker-git/hooks/plan-to-git-claude-hook"') + expect(hooks).toContain('ensureEventHook("UserPromptSubmit")') + expect(hooks).toContain('ensureEventHook("Stop")') expect(hooks).toContain("[features]") expect(hooks).toContain("hooks = true") expect(hooks).toContain('managed_dir = "/opt/docker-git/hooks"') @@ -522,15 +543,17 @@ describe("renderEntrypointGitHooks", () => { expect(hooks).toContain("[session-backup] Error: gh CLI not found") const cdIndex = hooks.indexOf('cd "$REPO_ROOT"') - const ensurePrIndex = hooks.indexOf("docker_git_ensure_open_pr\n\n# CHANGE: backfill Codex session plans") + const ensurePrIndex = hooks.indexOf("docker_git_ensure_open_pr\n\n# CHANGE: backfill agent session plans") const planImportIndex = hooks.indexOf("plan-to-git import-codex --no-sync") - const planSyncIndex = hooks.indexOf("plan-to-git sync") + const claudeImportIndex = hooks.indexOf("plan-to-git import-claude --no-sync") + const planSyncIndex = hooks.indexOf('"$PLAN_TO_GIT_SYNC_HELPER"', claudeImportIndex) const sessionBackupIndex = hooks.indexOf("docker-git-session-sync backup --verbose --background --require-comment") expect(cdIndex).toBeGreaterThanOrEqual(0) expect(ensurePrIndex).toBeGreaterThan(cdIndex) expect(planImportIndex).toBeGreaterThan(ensurePrIndex) - expect(planSyncIndex).toBeGreaterThan(planImportIndex) + expect(claudeImportIndex).toBeGreaterThan(planImportIndex) + expect(planSyncIndex).toBeGreaterThan(claudeImportIndex) expect(sessionBackupIndex).toBeGreaterThan(planSyncIndex) }) }) From 9879e5a1e5a46fddf2b20d94116eabe41315fbf5 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:19:57 +0000 Subject: [PATCH 2/2] fix(runtime): split plan-to-git hook templates --- .../core/templates-entrypoint/git-hooks.ts | 221 +---------------- .../core/templates-entrypoint/plan-to-git.ts | 223 ++++++++++++++++++ .../core/templates-entrypoint/git-hooks.ts | 221 +---------------- .../core/templates-entrypoint/plan-to-git.ts | 223 ++++++++++++++++++ 4 files changed, 466 insertions(+), 422 deletions(-) create mode 100644 packages/app/src/lib/core/templates-entrypoint/plan-to-git.ts create mode 100644 packages/lib/src/core/templates-entrypoint/plan-to-git.ts 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 77c69d88..eac12e71 100644 --- a/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts +++ b/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts @@ -1,4 +1,10 @@ import { renderEntrypointGitPostPushWrapperInstall } from "./git-post-push-wrapper.js" +import { + renderPlanToGitAgentHooksInstall, + renderPlanToGitHookPaths, + renderPlanToGitPostPushSync, + renderPlanToGitSyncHelperInstall +} from "./plan-to-git.js" import { renderPostPushPrEnsure } from "./post-push-pr.js" const entrypointGitHooksTemplate = String @@ -6,11 +12,7 @@ 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_SYNC_HELPER="$HOOKS_DIR/plan-to-git-sync" -PLAN_TO_GIT_CODEX_HOOK="$HOOKS_DIR/plan-to-git-codex-hook" -PLAN_TO_GIT_CLAUDE_HOOK="$HOOKS_DIR/plan-to-git-claude-hook" -CODEX_REQUIREMENTS_FILE="/etc/codex/requirements.toml" -CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="$CLAUDE_CONFIG_DIR/settings.json" +${renderPlanToGitHookPaths()} mkdir -p "$HOOKS_DIR" cat <<'EOF' > "$PRE_PUSH_HOOK" @@ -138,74 +140,7 @@ done EOF chmod 0755 "$PRE_PUSH_HOOK" -cat <<'EOF' > "$PLAN_TO_GIT_SYNC_HELPER" -#!/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 - -export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" - -docker_git_plan_to_git_explicit_pr_supported() { - plan-to-git sync --help 2>/dev/null | grep -q -- "--pr " -} - -docker_git_plan_to_git_resolve_pr_number() { - local candidate="" - local key="" - for key in DOCKER_GIT_PR_NUMBER PR_NUMBER GITHUB_PR_NUMBER; do - candidate="${"${"}!key:-}" - if [[ "$candidate" =~ ^[0-9]+$ ]]; then - printf "%s\n" "$candidate" - return 0 - fi - done - - candidate="${"${"}REPO_REF:-}" - if [[ "$candidate" =~ ^refs/pull/([0-9]+)/head$ ]]; then - printf "%s\n" "${"${"}BASH_REMATCH[1]}" - return 0 - fi - if [[ "$candidate" =~ ^pull/([0-9]+)$ ]]; then - printf "%s\n" "${"${"}BASH_REMATCH[1]}" - return 0 - fi - - if command -v gh >/dev/null 2>&1; then - candidate="$(gh pr view --json number --jq .number 2>/dev/null || true)" - if [[ "$candidate" =~ ^[0-9]+$ ]]; then - printf "%s\n" "$candidate" - return 0 - fi - fi - - return 0 -} - -docker_git_plan_to_git_sync() { - local pr_number="" - pr_number="$(docker_git_plan_to_git_resolve_pr_number || true)" - - if [[ -n "$pr_number" ]] && docker_git_plan_to_git_explicit_pr_supported; then - echo "[plan-to-git] Syncing queued agent plans to PR #$pr_number" - plan-to-git sync --pr "$pr_number" - return 0 - fi - - echo "[plan-to-git] Syncing queued agent plans via current branch discovery" - plan-to-git sync -} - -docker_git_plan_to_git_sync -EOF -chmod 0755 "$PLAN_TO_GIT_SYNC_HELPER" +${renderPlanToGitSyncHelperInstall()} cat <<'EOF' > "$POST_PUSH_ACTION" #!/usr/bin/env bash @@ -220,25 +155,7 @@ cd "$REPO_ROOT" ${renderPostPushPrEnsure()} -# CHANGE: backfill agent session plans before syncing the current branch or explicit PR. -# WHY: live agent hooks can be unavailable in already-running sessions; session logs are the durable fallback. -# QUOTE(ТЗ): "что бы всё уходило на гитхаб автоматически" -# REF: issue-397 -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 import-codex --no-sync - plan-to-git import-claude --no-sync - PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" - if [[ -x "$PLAN_TO_GIT_SYNC_HELPER" ]]; then - "$PLAN_TO_GIT_SYNC_HELPER" - else - echo "[plan-to-git] Sync helper not found; falling back to current branch discovery" >&2 - plan-to-git sync - fi -fi +${renderPlanToGitPostPushSync()} # 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 @@ -258,125 +175,7 @@ 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 - -export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" -plan-to-git hook --source codex -PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" -"$PLAN_TO_GIT_SYNC_HELPER" >&2 || true -EOF -chmod 0755 "$PLAN_TO_GIT_CODEX_HOOK" - -cat <<'EOF' > "$PLAN_TO_GIT_CLAUDE_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 - -export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" -plan-to-git hook --source claude -PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" -"$PLAN_TO_GIT_SYNC_HELPER" >&2 || true -EOF -chmod 0755 "$PLAN_TO_GIT_CLAUDE_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" - -docker_git_install_claude_plan_to_git_hooks() { - if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" = "1" ]; then - return 0 - fi - - CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="${"${"}CLAUDE_PLAN_TO_GIT_SETTINGS_FILE:-${"${"}CLAUDE_CONFIG_DIR:-/home/dev/.claude}/settings.json}" - CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" PLAN_TO_GIT_CLAUDE_HOOK="$PLAN_TO_GIT_CLAUDE_HOOK" node - <<'NODE' -const fs = require("node:fs") -const path = require("node:path") - -const settingsPath = process.env.CLAUDE_PLAN_TO_GIT_SETTINGS_FILE -const hookCommand = process.env.PLAN_TO_GIT_CLAUDE_HOOK || "/opt/docker-git/hooks/plan-to-git-claude-hook" -if (typeof settingsPath !== "string" || settingsPath.length === 0) { - process.exit(0) -} - -const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) - -let settings = {} -try { - const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) - settings = isRecord(parsed) ? parsed : {} -} catch { - settings = {} -} - -const currentHooks = isRecord(settings.hooks) ? settings.hooks : {} -const nextHooks = { ...currentHooks } -const managedHook = { type: "command", command: hookCommand } -const ensureEventHook = (eventName) => { - const currentEventHooks = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] - const alreadyInstalled = currentEventHooks.some((entry) => - isRecord(entry) && - Array.isArray(entry.hooks) && - entry.hooks.some((hook) => isRecord(hook) && hook.type === "command" && hook.command === hookCommand) - ) - nextHooks[eventName] = alreadyInstalled ? currentEventHooks : [...currentEventHooks, { hooks: [managedHook] }] -} - -ensureEventHook("UserPromptSubmit") -ensureEventHook("Stop") - -const nextSettings = { ...settings, hooks: nextHooks } -if (JSON.stringify(settings) === JSON.stringify(nextSettings)) { - process.exit(0) -} - -fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) -fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) -NODE - chmod 0600 "$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" 2>/dev/null || true - chown 1000:1000 "$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" 2>/dev/null || true -} - -docker_git_install_claude_plan_to_git_hooks +${renderPlanToGitAgentHooksInstall()} ${renderEntrypointGitPostPushWrapperInstall()} diff --git a/packages/app/src/lib/core/templates-entrypoint/plan-to-git.ts b/packages/app/src/lib/core/templates-entrypoint/plan-to-git.ts new file mode 100644 index 00000000..dac9e855 --- /dev/null +++ b/packages/app/src/lib/core/templates-entrypoint/plan-to-git.ts @@ -0,0 +1,223 @@ +const planToGitHookPathsTemplate = String.raw`PLAN_TO_GIT_SYNC_HELPER="$HOOKS_DIR/plan-to-git-sync" +PLAN_TO_GIT_CODEX_HOOK="$HOOKS_DIR/plan-to-git-codex-hook" +PLAN_TO_GIT_CLAUDE_HOOK="$HOOKS_DIR/plan-to-git-claude-hook" +CODEX_REQUIREMENTS_FILE="/etc/codex/requirements.toml" +CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="$CLAUDE_CONFIG_DIR/settings.json"` + +const planToGitSyncHelperInstallTemplate = String.raw`cat <<'EOF' > "$PLAN_TO_GIT_SYNC_HELPER" +#!/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 + +export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" + +docker_git_plan_to_git_explicit_pr_supported() { + plan-to-git sync --help 2>/dev/null | grep -q -- "--pr " +} + +docker_git_plan_to_git_resolve_pr_number() { + local candidate="" + local key="" + for key in DOCKER_GIT_PR_NUMBER PR_NUMBER GITHUB_PR_NUMBER; do + candidate="${"${"}!key:-}" + if [[ "$candidate" =~ ^[0-9]+$ ]]; then + printf "%s\n" "$candidate" + return 0 + fi + done + + candidate="${"${"}REPO_REF:-}" + if [[ "$candidate" =~ ^refs/pull/([0-9]+)/head$ ]]; then + printf "%s\n" "${"${"}BASH_REMATCH[1]}" + return 0 + fi + if [[ "$candidate" =~ ^pull/([0-9]+)$ ]]; then + printf "%s\n" "${"${"}BASH_REMATCH[1]}" + return 0 + fi + + if command -v gh >/dev/null 2>&1; then + candidate="$(gh pr view --json number --jq .number 2>/dev/null || true)" + if [[ "$candidate" =~ ^[0-9]+$ ]]; then + printf "%s\n" "$candidate" + return 0 + fi + fi + + return 0 +} + +docker_git_plan_to_git_sync() { + local pr_number="" + pr_number="$(docker_git_plan_to_git_resolve_pr_number || true)" + + if [[ -n "$pr_number" ]] && docker_git_plan_to_git_explicit_pr_supported; then + echo "[plan-to-git] Syncing queued agent plans to PR #$pr_number" + plan-to-git sync --pr "$pr_number" + return 0 + fi + + echo "[plan-to-git] Syncing queued agent plans via current branch discovery" + plan-to-git sync +} + +docker_git_plan_to_git_sync +EOF +chmod 0755 "$PLAN_TO_GIT_SYNC_HELPER"` + +const planToGitPostPushSyncTemplate = String + .raw`# CHANGE: backfill agent session plans before syncing the current branch or explicit PR. +# WHY: live agent hooks can be unavailable in already-running sessions; session logs are the durable fallback. +# QUOTE(ТЗ): "что бы всё уходило на гитхаб автоматически" +# REF: issue-397 +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 import-codex --no-sync + plan-to-git import-claude --no-sync + PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" + if [[ -x "$PLAN_TO_GIT_SYNC_HELPER" ]]; then + "$PLAN_TO_GIT_SYNC_HELPER" + else + echo "[plan-to-git] Sync helper not found; falling back to current branch discovery" >&2 + plan-to-git sync + fi +fi` + +const planToGitAgentHooksInstallTemplate = String.raw`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 + +export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" +plan-to-git hook --source codex +PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" +"$PLAN_TO_GIT_SYNC_HELPER" >&2 || true +EOF +chmod 0755 "$PLAN_TO_GIT_CODEX_HOOK" + +cat <<'EOF' > "$PLAN_TO_GIT_CLAUDE_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 + +export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" +plan-to-git hook --source claude +PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" +"$PLAN_TO_GIT_SYNC_HELPER" >&2 || true +EOF +chmod 0755 "$PLAN_TO_GIT_CLAUDE_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" + +docker_git_install_claude_plan_to_git_hooks() { + if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" = "1" ]; then + return 0 + fi + + CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="${"${"}CLAUDE_PLAN_TO_GIT_SETTINGS_FILE:-${"${"}CLAUDE_CONFIG_DIR:-/home/dev/.claude}/settings.json}" + CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" PLAN_TO_GIT_CLAUDE_HOOK="$PLAN_TO_GIT_CLAUDE_HOOK" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") + +const settingsPath = process.env.CLAUDE_PLAN_TO_GIT_SETTINGS_FILE +const hookCommand = process.env.PLAN_TO_GIT_CLAUDE_HOOK || "/opt/docker-git/hooks/plan-to-git-claude-hook" +if (typeof settingsPath !== "string" || settingsPath.length === 0) { + process.exit(0) +} + +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) + +let settings = {} +try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) + settings = isRecord(parsed) ? parsed : {} +} catch { + settings = {} +} + +const currentHooks = isRecord(settings.hooks) ? settings.hooks : {} +const nextHooks = { ...currentHooks } +const managedHook = { type: "command", command: hookCommand } +const ensureEventHook = (eventName) => { + const currentEventHooks = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] + const alreadyInstalled = currentEventHooks.some((entry) => + isRecord(entry) && + Array.isArray(entry.hooks) && + entry.hooks.some((hook) => isRecord(hook) && hook.type === "command" && hook.command === hookCommand) + ) + nextHooks[eventName] = alreadyInstalled ? currentEventHooks : [...currentEventHooks, { hooks: [managedHook] }] +} + +ensureEventHook("UserPromptSubmit") +ensureEventHook("Stop") + +const nextSettings = { ...settings, hooks: nextHooks } +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) { + process.exit(0) +} + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE + chmod 0600 "$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" 2>/dev/null || true + chown 1000:1000 "$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" 2>/dev/null || true +} + +docker_git_install_claude_plan_to_git_hooks` + +export const renderPlanToGitHookPaths = (): string => planToGitHookPathsTemplate + +export const renderPlanToGitSyncHelperInstall = (): string => planToGitSyncHelperInstallTemplate + +export const renderPlanToGitPostPushSync = (): string => planToGitPostPushSyncTemplate + +export const renderPlanToGitAgentHooksInstall = (): string => planToGitAgentHooksInstallTemplate diff --git a/packages/lib/src/core/templates-entrypoint/git-hooks.ts b/packages/lib/src/core/templates-entrypoint/git-hooks.ts index 77c69d88..eac12e71 100644 --- a/packages/lib/src/core/templates-entrypoint/git-hooks.ts +++ b/packages/lib/src/core/templates-entrypoint/git-hooks.ts @@ -1,4 +1,10 @@ import { renderEntrypointGitPostPushWrapperInstall } from "./git-post-push-wrapper.js" +import { + renderPlanToGitAgentHooksInstall, + renderPlanToGitHookPaths, + renderPlanToGitPostPushSync, + renderPlanToGitSyncHelperInstall +} from "./plan-to-git.js" import { renderPostPushPrEnsure } from "./post-push-pr.js" const entrypointGitHooksTemplate = String @@ -6,11 +12,7 @@ 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_SYNC_HELPER="$HOOKS_DIR/plan-to-git-sync" -PLAN_TO_GIT_CODEX_HOOK="$HOOKS_DIR/plan-to-git-codex-hook" -PLAN_TO_GIT_CLAUDE_HOOK="$HOOKS_DIR/plan-to-git-claude-hook" -CODEX_REQUIREMENTS_FILE="/etc/codex/requirements.toml" -CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="$CLAUDE_CONFIG_DIR/settings.json" +${renderPlanToGitHookPaths()} mkdir -p "$HOOKS_DIR" cat <<'EOF' > "$PRE_PUSH_HOOK" @@ -138,74 +140,7 @@ done EOF chmod 0755 "$PRE_PUSH_HOOK" -cat <<'EOF' > "$PLAN_TO_GIT_SYNC_HELPER" -#!/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 - -export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" - -docker_git_plan_to_git_explicit_pr_supported() { - plan-to-git sync --help 2>/dev/null | grep -q -- "--pr " -} - -docker_git_plan_to_git_resolve_pr_number() { - local candidate="" - local key="" - for key in DOCKER_GIT_PR_NUMBER PR_NUMBER GITHUB_PR_NUMBER; do - candidate="${"${"}!key:-}" - if [[ "$candidate" =~ ^[0-9]+$ ]]; then - printf "%s\n" "$candidate" - return 0 - fi - done - - candidate="${"${"}REPO_REF:-}" - if [[ "$candidate" =~ ^refs/pull/([0-9]+)/head$ ]]; then - printf "%s\n" "${"${"}BASH_REMATCH[1]}" - return 0 - fi - if [[ "$candidate" =~ ^pull/([0-9]+)$ ]]; then - printf "%s\n" "${"${"}BASH_REMATCH[1]}" - return 0 - fi - - if command -v gh >/dev/null 2>&1; then - candidate="$(gh pr view --json number --jq .number 2>/dev/null || true)" - if [[ "$candidate" =~ ^[0-9]+$ ]]; then - printf "%s\n" "$candidate" - return 0 - fi - fi - - return 0 -} - -docker_git_plan_to_git_sync() { - local pr_number="" - pr_number="$(docker_git_plan_to_git_resolve_pr_number || true)" - - if [[ -n "$pr_number" ]] && docker_git_plan_to_git_explicit_pr_supported; then - echo "[plan-to-git] Syncing queued agent plans to PR #$pr_number" - plan-to-git sync --pr "$pr_number" - return 0 - fi - - echo "[plan-to-git] Syncing queued agent plans via current branch discovery" - plan-to-git sync -} - -docker_git_plan_to_git_sync -EOF -chmod 0755 "$PLAN_TO_GIT_SYNC_HELPER" +${renderPlanToGitSyncHelperInstall()} cat <<'EOF' > "$POST_PUSH_ACTION" #!/usr/bin/env bash @@ -220,25 +155,7 @@ cd "$REPO_ROOT" ${renderPostPushPrEnsure()} -# CHANGE: backfill agent session plans before syncing the current branch or explicit PR. -# WHY: live agent hooks can be unavailable in already-running sessions; session logs are the durable fallback. -# QUOTE(ТЗ): "что бы всё уходило на гитхаб автоматически" -# REF: issue-397 -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 import-codex --no-sync - plan-to-git import-claude --no-sync - PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" - if [[ -x "$PLAN_TO_GIT_SYNC_HELPER" ]]; then - "$PLAN_TO_GIT_SYNC_HELPER" - else - echo "[plan-to-git] Sync helper not found; falling back to current branch discovery" >&2 - plan-to-git sync - fi -fi +${renderPlanToGitPostPushSync()} # 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 @@ -258,125 +175,7 @@ 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 - -export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" -plan-to-git hook --source codex -PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" -"$PLAN_TO_GIT_SYNC_HELPER" >&2 || true -EOF -chmod 0755 "$PLAN_TO_GIT_CODEX_HOOK" - -cat <<'EOF' > "$PLAN_TO_GIT_CLAUDE_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 - -export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" -plan-to-git hook --source claude -PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" -"$PLAN_TO_GIT_SYNC_HELPER" >&2 || true -EOF -chmod 0755 "$PLAN_TO_GIT_CLAUDE_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" - -docker_git_install_claude_plan_to_git_hooks() { - if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" = "1" ]; then - return 0 - fi - - CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="${"${"}CLAUDE_PLAN_TO_GIT_SETTINGS_FILE:-${"${"}CLAUDE_CONFIG_DIR:-/home/dev/.claude}/settings.json}" - CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" PLAN_TO_GIT_CLAUDE_HOOK="$PLAN_TO_GIT_CLAUDE_HOOK" node - <<'NODE' -const fs = require("node:fs") -const path = require("node:path") - -const settingsPath = process.env.CLAUDE_PLAN_TO_GIT_SETTINGS_FILE -const hookCommand = process.env.PLAN_TO_GIT_CLAUDE_HOOK || "/opt/docker-git/hooks/plan-to-git-claude-hook" -if (typeof settingsPath !== "string" || settingsPath.length === 0) { - process.exit(0) -} - -const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) - -let settings = {} -try { - const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) - settings = isRecord(parsed) ? parsed : {} -} catch { - settings = {} -} - -const currentHooks = isRecord(settings.hooks) ? settings.hooks : {} -const nextHooks = { ...currentHooks } -const managedHook = { type: "command", command: hookCommand } -const ensureEventHook = (eventName) => { - const currentEventHooks = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] - const alreadyInstalled = currentEventHooks.some((entry) => - isRecord(entry) && - Array.isArray(entry.hooks) && - entry.hooks.some((hook) => isRecord(hook) && hook.type === "command" && hook.command === hookCommand) - ) - nextHooks[eventName] = alreadyInstalled ? currentEventHooks : [...currentEventHooks, { hooks: [managedHook] }] -} - -ensureEventHook("UserPromptSubmit") -ensureEventHook("Stop") - -const nextSettings = { ...settings, hooks: nextHooks } -if (JSON.stringify(settings) === JSON.stringify(nextSettings)) { - process.exit(0) -} - -fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) -fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) -NODE - chmod 0600 "$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" 2>/dev/null || true - chown 1000:1000 "$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" 2>/dev/null || true -} - -docker_git_install_claude_plan_to_git_hooks +${renderPlanToGitAgentHooksInstall()} ${renderEntrypointGitPostPushWrapperInstall()} diff --git a/packages/lib/src/core/templates-entrypoint/plan-to-git.ts b/packages/lib/src/core/templates-entrypoint/plan-to-git.ts new file mode 100644 index 00000000..dac9e855 --- /dev/null +++ b/packages/lib/src/core/templates-entrypoint/plan-to-git.ts @@ -0,0 +1,223 @@ +const planToGitHookPathsTemplate = String.raw`PLAN_TO_GIT_SYNC_HELPER="$HOOKS_DIR/plan-to-git-sync" +PLAN_TO_GIT_CODEX_HOOK="$HOOKS_DIR/plan-to-git-codex-hook" +PLAN_TO_GIT_CLAUDE_HOOK="$HOOKS_DIR/plan-to-git-claude-hook" +CODEX_REQUIREMENTS_FILE="/etc/codex/requirements.toml" +CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="$CLAUDE_CONFIG_DIR/settings.json"` + +const planToGitSyncHelperInstallTemplate = String.raw`cat <<'EOF' > "$PLAN_TO_GIT_SYNC_HELPER" +#!/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 + +export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" + +docker_git_plan_to_git_explicit_pr_supported() { + plan-to-git sync --help 2>/dev/null | grep -q -- "--pr " +} + +docker_git_plan_to_git_resolve_pr_number() { + local candidate="" + local key="" + for key in DOCKER_GIT_PR_NUMBER PR_NUMBER GITHUB_PR_NUMBER; do + candidate="${"${"}!key:-}" + if [[ "$candidate" =~ ^[0-9]+$ ]]; then + printf "%s\n" "$candidate" + return 0 + fi + done + + candidate="${"${"}REPO_REF:-}" + if [[ "$candidate" =~ ^refs/pull/([0-9]+)/head$ ]]; then + printf "%s\n" "${"${"}BASH_REMATCH[1]}" + return 0 + fi + if [[ "$candidate" =~ ^pull/([0-9]+)$ ]]; then + printf "%s\n" "${"${"}BASH_REMATCH[1]}" + return 0 + fi + + if command -v gh >/dev/null 2>&1; then + candidate="$(gh pr view --json number --jq .number 2>/dev/null || true)" + if [[ "$candidate" =~ ^[0-9]+$ ]]; then + printf "%s\n" "$candidate" + return 0 + fi + fi + + return 0 +} + +docker_git_plan_to_git_sync() { + local pr_number="" + pr_number="$(docker_git_plan_to_git_resolve_pr_number || true)" + + if [[ -n "$pr_number" ]] && docker_git_plan_to_git_explicit_pr_supported; then + echo "[plan-to-git] Syncing queued agent plans to PR #$pr_number" + plan-to-git sync --pr "$pr_number" + return 0 + fi + + echo "[plan-to-git] Syncing queued agent plans via current branch discovery" + plan-to-git sync +} + +docker_git_plan_to_git_sync +EOF +chmod 0755 "$PLAN_TO_GIT_SYNC_HELPER"` + +const planToGitPostPushSyncTemplate = String + .raw`# CHANGE: backfill agent session plans before syncing the current branch or explicit PR. +# WHY: live agent hooks can be unavailable in already-running sessions; session logs are the durable fallback. +# QUOTE(ТЗ): "что бы всё уходило на гитхаб автоматически" +# REF: issue-397 +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 import-codex --no-sync + plan-to-git import-claude --no-sync + PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" + if [[ -x "$PLAN_TO_GIT_SYNC_HELPER" ]]; then + "$PLAN_TO_GIT_SYNC_HELPER" + else + echo "[plan-to-git] Sync helper not found; falling back to current branch discovery" >&2 + plan-to-git sync + fi +fi` + +const planToGitAgentHooksInstallTemplate = String.raw`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 + +export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" +plan-to-git hook --source codex +PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" +"$PLAN_TO_GIT_SYNC_HELPER" >&2 || true +EOF +chmod 0755 "$PLAN_TO_GIT_CODEX_HOOK" + +cat <<'EOF' > "$PLAN_TO_GIT_CLAUDE_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 + +export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" +plan-to-git hook --source claude +PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" +"$PLAN_TO_GIT_SYNC_HELPER" >&2 || true +EOF +chmod 0755 "$PLAN_TO_GIT_CLAUDE_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" + +docker_git_install_claude_plan_to_git_hooks() { + if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" = "1" ]; then + return 0 + fi + + CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="${"${"}CLAUDE_PLAN_TO_GIT_SETTINGS_FILE:-${"${"}CLAUDE_CONFIG_DIR:-/home/dev/.claude}/settings.json}" + CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" PLAN_TO_GIT_CLAUDE_HOOK="$PLAN_TO_GIT_CLAUDE_HOOK" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") + +const settingsPath = process.env.CLAUDE_PLAN_TO_GIT_SETTINGS_FILE +const hookCommand = process.env.PLAN_TO_GIT_CLAUDE_HOOK || "/opt/docker-git/hooks/plan-to-git-claude-hook" +if (typeof settingsPath !== "string" || settingsPath.length === 0) { + process.exit(0) +} + +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) + +let settings = {} +try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) + settings = isRecord(parsed) ? parsed : {} +} catch { + settings = {} +} + +const currentHooks = isRecord(settings.hooks) ? settings.hooks : {} +const nextHooks = { ...currentHooks } +const managedHook = { type: "command", command: hookCommand } +const ensureEventHook = (eventName) => { + const currentEventHooks = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] + const alreadyInstalled = currentEventHooks.some((entry) => + isRecord(entry) && + Array.isArray(entry.hooks) && + entry.hooks.some((hook) => isRecord(hook) && hook.type === "command" && hook.command === hookCommand) + ) + nextHooks[eventName] = alreadyInstalled ? currentEventHooks : [...currentEventHooks, { hooks: [managedHook] }] +} + +ensureEventHook("UserPromptSubmit") +ensureEventHook("Stop") + +const nextSettings = { ...settings, hooks: nextHooks } +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) { + process.exit(0) +} + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE + chmod 0600 "$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" 2>/dev/null || true + chown 1000:1000 "$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" 2>/dev/null || true +} + +docker_git_install_claude_plan_to_git_hooks` + +export const renderPlanToGitHookPaths = (): string => planToGitHookPathsTemplate + +export const renderPlanToGitSyncHelperInstall = (): string => planToGitSyncHelperInstallTemplate + +export const renderPlanToGitPostPushSync = (): string => planToGitPostPushSyncTemplate + +export const renderPlanToGitAgentHooksInstall = (): string => planToGitAgentHooksInstallTemplate