Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/plan-to-git-claude-hooks.md
Original file line number Diff line number Diff line change
@@ -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.
64 changes: 11 additions & 53 deletions packages/app/src/lib/core/templates-entrypoint/git-hooks.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
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
.raw`# 3) Install global git hooks to protect main/master + managed AGENTS context
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"
${renderPlanToGitHookPaths()}
mkdir -p "$HOOKS_DIR"

cat <<'EOF' > "$PRE_PUSH_HOOK"
Expand Down Expand Up @@ -135,6 +140,8 @@ done
EOF
chmod 0755 "$PRE_PUSH_HOOK"

${renderPlanToGitSyncHelperInstall()}

cat <<'EOF' > "$POST_PUSH_ACTION"
#!/usr/bin/env bash
set -euo pipefail
Expand All @@ -148,17 +155,7 @@ 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
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
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
Expand All @@ -178,46 +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

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"
${renderPlanToGitAgentHooksInstall()}

${renderEntrypointGitPostPushWrapperInstall()}

Expand Down
223 changes: 223 additions & 0 deletions packages/app/src/lib/core/templates-entrypoint/plan-to-git.ts
Original file line number Diff line number Diff line change
@@ -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 <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
18 changes: 10 additions & 8 deletions packages/app/src/lib/core/templates/dockerfile-prelude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <PR>"`

/**
* Renders the base image, package prelude, Rust toolchain, browser module, and plan sync CLI install.
Expand Down
Loading
Loading