Skip to content

Commit 8a14af1

Browse files
committed
feat(runtime): wire plan-to-git claude hooks and pr sync
1 parent 8889c5c commit 8a14af1

8 files changed

Lines changed: 404 additions & 35 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@effect-template/lib": patch
3+
"@prover-coder-ai/docker-git": patch
4+
---
5+
6+
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.

packages/app/src/lib/core/templates-entrypoint/git-hooks.ts

Lines changed: 163 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ const entrypointGitHooksTemplate = String
66
HOOKS_DIR="/opt/docker-git/hooks"
77
PRE_PUSH_HOOK="$HOOKS_DIR/pre-push"
88
POST_PUSH_ACTION="$HOOKS_DIR/post-push"
9+
PLAN_TO_GIT_SYNC_HELPER="$HOOKS_DIR/plan-to-git-sync"
910
PLAN_TO_GIT_CODEX_HOOK="$HOOKS_DIR/plan-to-git-codex-hook"
11+
PLAN_TO_GIT_CLAUDE_HOOK="$HOOKS_DIR/plan-to-git-claude-hook"
1012
CODEX_REQUIREMENTS_FILE="/etc/codex/requirements.toml"
13+
CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="$CLAUDE_CONFIG_DIR/settings.json"
1114
mkdir -p "$HOOKS_DIR"
1215
1316
cat <<'EOF' > "$PRE_PUSH_HOOK"
@@ -135,6 +138,75 @@ done
135138
EOF
136139
chmod 0755 "$PRE_PUSH_HOOK"
137140
141+
cat <<'EOF' > "$PLAN_TO_GIT_SYNC_HELPER"
142+
#!/usr/bin/env bash
143+
set -euo pipefail
144+
145+
if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" = "1" ]; then
146+
exit 0
147+
fi
148+
149+
if ! command -v plan-to-git >/dev/null 2>&1; then
150+
echo "[plan-to-git] Error: plan-to-git not found" >&2
151+
exit 1
152+
fi
153+
154+
export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}"
155+
156+
docker_git_plan_to_git_explicit_pr_supported() {
157+
plan-to-git sync --help 2>/dev/null | grep -q -- "--pr <PR>"
158+
}
159+
160+
docker_git_plan_to_git_resolve_pr_number() {
161+
local candidate=""
162+
local key=""
163+
for key in DOCKER_GIT_PR_NUMBER PR_NUMBER GITHUB_PR_NUMBER; do
164+
candidate="${"${"}!key:-}"
165+
if [[ "$candidate" =~ ^[0-9]+$ ]]; then
166+
printf "%s\n" "$candidate"
167+
return 0
168+
fi
169+
done
170+
171+
candidate="${"${"}REPO_REF:-}"
172+
if [[ "$candidate" =~ ^refs/pull/([0-9]+)/head$ ]]; then
173+
printf "%s\n" "${"${"}BASH_REMATCH[1]}"
174+
return 0
175+
fi
176+
if [[ "$candidate" =~ ^pull/([0-9]+)$ ]]; then
177+
printf "%s\n" "${"${"}BASH_REMATCH[1]}"
178+
return 0
179+
fi
180+
181+
if command -v gh >/dev/null 2>&1; then
182+
candidate="$(gh pr view --json number --jq .number 2>/dev/null || true)"
183+
if [[ "$candidate" =~ ^[0-9]+$ ]]; then
184+
printf "%s\n" "$candidate"
185+
return 0
186+
fi
187+
fi
188+
189+
return 0
190+
}
191+
192+
docker_git_plan_to_git_sync() {
193+
local pr_number=""
194+
pr_number="$(docker_git_plan_to_git_resolve_pr_number || true)"
195+
196+
if [[ -n "$pr_number" ]] && docker_git_plan_to_git_explicit_pr_supported; then
197+
echo "[plan-to-git] Syncing queued agent plans to PR #$pr_number"
198+
plan-to-git sync --pr "$pr_number"
199+
return 0
200+
fi
201+
202+
echo "[plan-to-git] Syncing queued agent plans via current branch discovery"
203+
plan-to-git sync
204+
}
205+
206+
docker_git_plan_to_git_sync
207+
EOF
208+
chmod 0755 "$PLAN_TO_GIT_SYNC_HELPER"
209+
138210
cat <<'EOF' > "$POST_PUSH_ACTION"
139211
#!/usr/bin/env bash
140212
set -euo pipefail
@@ -148,16 +220,24 @@ cd "$REPO_ROOT"
148220
149221
${renderPostPushPrEnsure()}
150222
151-
# CHANGE: backfill Codex session plans before syncing the current branch PR.
152-
# WHY: live Codex hooks can be unavailable in already-running sessions; session logs are the durable fallback.
153-
# REF: issue-375
223+
# CHANGE: backfill agent session plans before syncing the current branch or explicit PR.
224+
# WHY: live agent hooks can be unavailable in already-running sessions; session logs are the durable fallback.
225+
# QUOTE(ТЗ): "что бы всё уходило на гитхаб автоматически"
226+
# REF: issue-397
154227
if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" != "1" ]; then
155228
if ! command -v plan-to-git >/dev/null 2>&1; then
156229
echo "[plan-to-git] Error: plan-to-git not found" >&2
157230
exit 1
158231
fi
159232
plan-to-git import-codex --no-sync
160-
plan-to-git sync
233+
plan-to-git import-claude --no-sync
234+
PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}"
235+
if [[ -x "$PLAN_TO_GIT_SYNC_HELPER" ]]; then
236+
"$PLAN_TO_GIT_SYNC_HELPER"
237+
else
238+
echo "[plan-to-git] Sync helper not found; falling back to current branch discovery" >&2
239+
plan-to-git sync
240+
fi
161241
fi
162242
163243
# 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
191271
exit 1
192272
fi
193273
274+
export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}"
194275
plan-to-git hook --source codex
276+
PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}"
277+
"$PLAN_TO_GIT_SYNC_HELPER" >&2 || true
195278
EOF
196279
chmod 0755 "$PLAN_TO_GIT_CODEX_HOOK"
197280
281+
cat <<'EOF' > "$PLAN_TO_GIT_CLAUDE_HOOK"
282+
#!/usr/bin/env bash
283+
set -euo pipefail
284+
285+
if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" = "1" ]; then
286+
exit 0
287+
fi
288+
289+
if ! command -v plan-to-git >/dev/null 2>&1; then
290+
echo "[plan-to-git] Error: plan-to-git not found" >&2
291+
exit 1
292+
fi
293+
294+
export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}"
295+
plan-to-git hook --source claude
296+
PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}"
297+
"$PLAN_TO_GIT_SYNC_HELPER" >&2 || true
298+
EOF
299+
chmod 0755 "$PLAN_TO_GIT_CLAUDE_HOOK"
300+
198301
mkdir -p "$(dirname "$CODEX_REQUIREMENTS_FILE")"
199302
cat <<'EOF' > "$CODEX_REQUIREMENTS_FILE"
200303
# docker-git managed Codex requirements
@@ -219,6 +322,62 @@ statusMessage = "Capturing agent plan"
219322
EOF
220323
chmod 0644 "$CODEX_REQUIREMENTS_FILE"
221324
325+
docker_git_install_claude_plan_to_git_hooks() {
326+
if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" = "1" ]; then
327+
return 0
328+
fi
329+
330+
CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="${"${"}CLAUDE_PLAN_TO_GIT_SETTINGS_FILE:-${"${"}CLAUDE_CONFIG_DIR:-/home/dev/.claude}/settings.json}"
331+
CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" PLAN_TO_GIT_CLAUDE_HOOK="$PLAN_TO_GIT_CLAUDE_HOOK" node - <<'NODE'
332+
const fs = require("node:fs")
333+
const path = require("node:path")
334+
335+
const settingsPath = process.env.CLAUDE_PLAN_TO_GIT_SETTINGS_FILE
336+
const hookCommand = process.env.PLAN_TO_GIT_CLAUDE_HOOK || "/opt/docker-git/hooks/plan-to-git-claude-hook"
337+
if (typeof settingsPath !== "string" || settingsPath.length === 0) {
338+
process.exit(0)
339+
}
340+
341+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value)
342+
343+
let settings = {}
344+
try {
345+
const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8"))
346+
settings = isRecord(parsed) ? parsed : {}
347+
} catch {
348+
settings = {}
349+
}
350+
351+
const currentHooks = isRecord(settings.hooks) ? settings.hooks : {}
352+
const nextHooks = { ...currentHooks }
353+
const managedHook = { type: "command", command: hookCommand }
354+
const ensureEventHook = (eventName) => {
355+
const currentEventHooks = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
356+
const alreadyInstalled = currentEventHooks.some((entry) =>
357+
isRecord(entry) &&
358+
Array.isArray(entry.hooks) &&
359+
entry.hooks.some((hook) => isRecord(hook) && hook.type === "command" && hook.command === hookCommand)
360+
)
361+
nextHooks[eventName] = alreadyInstalled ? currentEventHooks : [...currentEventHooks, { hooks: [managedHook] }]
362+
}
363+
364+
ensureEventHook("UserPromptSubmit")
365+
ensureEventHook("Stop")
366+
367+
const nextSettings = { ...settings, hooks: nextHooks }
368+
if (JSON.stringify(settings) === JSON.stringify(nextSettings)) {
369+
process.exit(0)
370+
}
371+
372+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true })
373+
fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 })
374+
NODE
375+
chmod 0600 "$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" 2>/dev/null || true
376+
chown 1000:1000 "$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" 2>/dev/null || true
377+
}
378+
379+
docker_git_install_claude_plan_to_git_hooks
380+
222381
${renderEntrypointGitPostPushWrapperInstall()}
223382
224383
git config --system core.hooksPath "$HOOKS_DIR" || true

packages/app/src/lib/core/templates/dockerfile-prelude.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -83,22 +83,24 @@ RUN cargo install --git https://github.com/ProverCoderAI/rust-browser-connection
8383
RUN printf "%s\\n" "ALL ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/zz-all \
8484
&& chmod 0440 /etc/sudoers.d/zz-all`
8585

86-
const planToGitRevision = "06fe8bdf1d2e48a1f5a0218a3bb7af19e63deb5e"
86+
const planToGitRevision = "f60fbe71131854be4c6c1d9fb79abafd2dd6949b"
8787

8888
// CHANGE: install plan-to-git in generated project containers.
89-
// WHY: issue #369 requires agent plans to be captured and uploaded to pull requests.
90-
// QUOTE(ТЗ): "Надо что бы у нас план загружался в PR"
91-
// REF: issue-369
92-
// SOURCE: https://github.com/ProverCoderAI/plan-to-git/tree/v0.19.0
89+
// WHY: issue #397 requires multi-agent plan capture, Claude Code hooks, temp-backed state, and explicit PR sync.
90+
// QUOTE(ТЗ): "подключение новое версии plan-to-git и настройки hooks для claude code и настройки что бы всё уходило на гитхаб автоматически"
91+
// REF: issue-397
92+
// SOURCE: https://github.com/ProverCoderAI/plan-to-git/tree/f60fbe71131854be4c6c1d9fb79abafd2dd6949b
9393
// FORMAT THEOREM: image_build_success -> executable(/usr/local/bin/plan-to-git)
9494
// PURITY: SHELL
9595
// EFFECT: Docker build downloads and installs a pinned Rust CLI from GitHub.
96-
// INVARIANT: plan-to-git is available on PATH before Codex hooks or git post-push actions run.
96+
// INVARIANT: plan-to-git is available on PATH with Claude hooks and sync --pr before agent hooks or git post-push actions run.
9797
// COMPLEXITY: O(network + cargo_build)
9898
const renderDockerfilePlanToGit = (): string =>
99-
`# Install plan-to-git for Codex plan capture and PR sync (issue #369)
99+
`# Install plan-to-git for multi-agent plan capture and explicit PR sync (issue #397)
100100
RUN cargo install --git https://github.com/ProverCoderAI/plan-to-git --rev ${planToGitRevision} --locked --bins --root /usr/local \
101-
&& /usr/local/bin/plan-to-git --help >/dev/null`
101+
&& /usr/local/bin/plan-to-git --help >/dev/null \
102+
&& /usr/local/bin/plan-to-git hook --help | grep -q -- "claude" \
103+
&& /usr/local/bin/plan-to-git sync --help | grep -q -- "--pr <PR>"`
102104

103105
/**
104106
* Renders the base image, package prelude, Rust toolchain, browser module, and plan sync CLI install.

packages/app/tests/docker-git/core-templates.test.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,11 @@ describe("app planFiles", () => {
9393
"cargo install --git https://github.com/ProverCoderAI/rust-browser-connection"
9494
)
9595
expect(dockerfile.contents).toContain(
96-
"cargo install --git https://github.com/ProverCoderAI/plan-to-git --rev 06fe8bdf1d2e48a1f5a0218a3bb7af19e63deb5e --locked --bins --root /usr/local"
96+
"cargo install --git https://github.com/ProverCoderAI/plan-to-git --rev f60fbe71131854be4c6c1d9fb79abafd2dd6949b --locked --bins --root /usr/local"
9797
)
9898
expect(dockerfile.contents).toContain("/usr/local/bin/plan-to-git --help >/dev/null")
99+
expect(dockerfile.contents).toContain("/usr/local/bin/plan-to-git hook --help | grep -q -- \"claude\"")
100+
expect(dockerfile.contents).toContain("/usr/local/bin/plan-to-git sync --help | grep -q -- \"--pr <PR>\"")
99101
expect(dockerfile.contents).toContain("make build-essential docker.io")
100102
expect(dockerfile.contents).toContain("/usr/local/bin/browser-connection --version")
101103
expect(dockerfile.contents).not.toContain("docker-git-playwright-mcp")
@@ -110,33 +112,45 @@ describe("app planFiles", () => {
110112
"args = [\"--project\", \"$DOCKER_GIT_BROWSER_PROJECT\", \"--network\", \"$DOCKER_GIT_BROWSER_NETWORK\"]"
111113
)
112114
expect(entrypoint.contents).toContain("plan-to-git sync")
115+
expect(entrypoint.contents).toContain("PLAN_TO_GIT_SYNC_HELPER=\"$HOOKS_DIR/plan-to-git-sync\"")
116+
expect(entrypoint.contents).toContain("plan-to-git sync --pr \"$pr_number\"")
113117
expect(entrypoint.contents).toContain("docker_git_ensure_open_pr")
114118
expect(entrypoint.contents).toContain("gh pr list --repo \"$base_repo\" --state open --head \"$head_arg\"")
115119
expect(entrypoint.contents).toContain(
116120
"gh pr create --repo \"$base_repo\" --base \"$base_branch\" --head \"$head_arg\" --fill"
117121
)
118122
expect(entrypoint.contents).toContain("plan-to-git hook --source codex")
123+
expect(entrypoint.contents).toContain("plan-to-git hook --source claude")
119124
expect(entrypoint.contents).toContain("plan-to-git import-codex --no-sync")
125+
expect(entrypoint.contents).toContain("plan-to-git import-claude --no-sync")
120126
expect(entrypoint.contents).toContain("CODEX_REQUIREMENTS_FILE=\"/etc/codex/requirements.toml\"")
127+
expect(entrypoint.contents).toContain("PLAN_TO_GIT_CLAUDE_HOOK=\"$HOOKS_DIR/plan-to-git-claude-hook\"")
128+
expect(entrypoint.contents).toContain("docker_git_install_claude_plan_to_git_hooks")
129+
expect(entrypoint.contents).toContain("CLAUDE_PLAN_TO_GIT_SETTINGS_FILE=\"$CLAUDE_CONFIG_DIR/settings.json\"")
130+
expect(entrypoint.contents).toContain("ensureEventHook(\"UserPromptSubmit\")")
131+
expect(entrypoint.contents).toContain("ensureEventHook(\"Stop\")")
132+
expect(entrypoint.contents).toContain("PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git")
121133
expect(entrypoint.contents).toContain("managed_dir = \"/opt/docker-git/hooks\"")
122134
expect(entrypoint.contents).toContain("[[hooks.UserPromptSubmit]]")
123135
expect(entrypoint.contents).toContain("[[hooks.Stop]]")
124136
expect(entrypoint.contents).toContain("command = \"/opt/docker-git/hooks/plan-to-git-codex-hook\"")
125137

126138
const cdIndex = entrypoint.contents.indexOf("cd \"$REPO_ROOT\"")
127139
const ensurePrIndex = entrypoint.contents.indexOf(
128-
"docker_git_ensure_open_pr\n\n# CHANGE: backfill Codex session plans"
140+
"docker_git_ensure_open_pr\n\n# CHANGE: backfill agent session plans"
129141
)
130142
const planImportIndex = entrypoint.contents.indexOf("plan-to-git import-codex --no-sync")
131-
const planSyncIndex = entrypoint.contents.indexOf("plan-to-git sync")
143+
const claudeImportIndex = entrypoint.contents.indexOf("plan-to-git import-claude --no-sync")
144+
const planSyncIndex = entrypoint.contents.indexOf("\"$PLAN_TO_GIT_SYNC_HELPER\"", claudeImportIndex)
132145
const sessionBackupIndex = entrypoint.contents.indexOf(
133146
"docker-git-session-sync backup --verbose --background --require-comment"
134147
)
135148

136149
expect(cdIndex).toBeGreaterThanOrEqual(0)
137150
expect(ensurePrIndex).toBeGreaterThan(cdIndex)
138151
expect(planImportIndex).toBeGreaterThan(ensurePrIndex)
139-
expect(planSyncIndex).toBeGreaterThan(planImportIndex)
152+
expect(claudeImportIndex).toBeGreaterThan(planImportIndex)
153+
expect(planSyncIndex).toBeGreaterThan(claudeImportIndex)
140154
expect(sessionBackupIndex).toBeGreaterThan(planSyncIndex)
141155
})
142156
)

0 commit comments

Comments
 (0)