chore(ai-workflow): tighten agent workflow and worktree tooling (#1107)

* chore(ai-workflow): tighten agent workflow and worktree tooling

* fix(ai-workflow): address review feedback

* fix(ai-workflow): make format hook portable
This commit is contained in:
Tommaso Casaburi
2026-03-17 19:58:30 +08:00
committed by GitHub
parent b9c7524f04
commit ebf5ab64e4
23 changed files with 561 additions and 455 deletions
+10
View File
@@ -0,0 +1,10 @@
# scripts/AGENTS.md
These rules apply to `scripts/**`. Follow the repo-root `AGENTS.md` first, then use this file for automation and workflow helpers.
- Keep scripts non-interactive and idempotent. Print the command, URL, branch, or path being acted on so failures are diagnosable.
- Use repo-relative paths and environment variables instead of user-specific absolute paths.
- For dev-server helpers, default to `http://5chan.localhost:1355` and respect the existing `PORTLESS=0` fallback instead of hard-coding alternate ports.
- Keep shell helpers thin. When logic becomes stateful or cross-platform, prefer a Node script.
- Git and worktree helpers must validate input and default to safe operations.
- If a helper deletes local branches automatically, document the exact eligibility checks and keep the behavior conservative.
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# afterFileEdit hook: Auto-format files after AI edits them
# Receives JSON via stdin: {"file_path": "...", "edits": [...]}
input=$(cat)
if ! command -v jq >/dev/null 2>&1; then
exit 0
fi
file_path=$(printf '%s' "$input" | jq -r '.file_path // empty' 2>/dev/null)
if [ -z "$file_path" ]; then
exit 0
fi
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$repo_root" || exit 0
case "$file_path" in
*.js|*.ts|*.tsx|*.mjs)
dir_part="${file_path%/*}"
base_name="${file_path##*/}"
if [ "$dir_part" = "$file_path" ]; then
dir_part="."
fi
resolved_dir="$(cd -P -- "$repo_root/$dir_part" 2>/dev/null && pwd -P)" || exit 0
resolved_path="$resolved_dir/$base_name"
case "$resolved_path" in
"$repo_root"/*) npx oxfmt "$resolved_path" 2>/dev/null || true ;;
*) exit 0 ;;
esac
;;
esac
exit 0
+115
View File
@@ -0,0 +1,115 @@
#!/bin/bash
# stop hook: prune stale remote refs and remove integrated temporary local branches
# This is informational - always exits 0
cat > /dev/null
cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" || exit 0
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
exit 0
fi
default_branch="$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')"
if [ -z "$default_branch" ]; then
default_branch="master"
fi
current_branch="$(git branch --show-current 2>/dev/null || true)"
branch_looks_temporary() {
case "$1" in
pr/*|feature/*|fix/*|docs/*|chore/*|codex/pr/*|codex/feature/*|codex/fix/*|codex/docs/*|codex/chore/*) return 0 ;;
*) return 1 ;;
esac
}
branch_is_integrated() {
local branch="$1"
local cherry_output
cherry_output="$(git cherry "$default_branch" "$branch" 2>/dev/null || true)"
if echo "$cherry_output" | grep -q '^+'; then
return 1
fi
return 0
}
branch_has_live_upstream() {
local upstream="$1"
[ -n "$upstream" ] && git show-ref --verify --quiet "refs/remotes/$upstream"
}
merged_pr_number_for_branch() {
local branch="$1"
local pr_number=""
if ! command -v gh >/dev/null 2>&1; then
return 0
fi
case "$branch" in
pr/*)
pr_number="${branch#pr/}"
;;
codex/pr/*)
pr_number="${branch#codex/pr/}"
;;
esac
if [ -n "$pr_number" ]; then
gh pr view "$pr_number" --repo bitsocialnet/5chan --json mergedAt --jq 'select(.mergedAt != null) | .mergedAt' >/dev/null 2>&1 || return 0
echo "$pr_number"
return 0
fi
gh pr list --repo bitsocialnet/5chan --state merged --head "$branch" --json number --jq '.[0].number // empty' 2>/dev/null || true
}
echo "Syncing git refs and temporary branches..."
echo ""
echo "=== git config --local fetch.prune true ==="
git config --local fetch.prune true 2>&1 || true
echo ""
echo "=== git config --local remote.origin.prune true ==="
git config --local remote.origin.prune true 2>&1 || true
echo ""
echo "=== git fetch --prune origin ==="
git fetch --prune origin 2>&1 || true
echo ""
while IFS='|' read -r branch upstream; do
local_pr_number=""
[ -z "$branch" ] && continue
[ "$branch" = "$current_branch" ] && continue
[ "$branch" = "$default_branch" ] && continue
branch_looks_temporary "$branch" || continue
local_pr_number="$(merged_pr_number_for_branch "$branch")"
if branch_has_live_upstream "$upstream"; then
continue
fi
if ! branch_is_integrated "$branch" && [ -z "$local_pr_number" ]; then
continue
fi
if [ -n "$local_pr_number" ]; then
echo "=== merged PR #$local_pr_number allows deleting $branch ==="
echo ""
fi
echo "=== git branch -d $branch ==="
git branch -d "$branch" 2>&1 || true
echo ""
done < <(git for-each-ref --format='%(refname:short)|%(upstream:short)' refs/heads)
echo "Git ref sync complete."
exit 0
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
# stop hook: run required repo verification commands for agent-driven changes
set -u
mode="${AGENT_VERIFY_MODE:-strict}"
if [ "${1:-}" = "--advisory" ]; then
mode="advisory"
shift
fi
cat > /dev/null
cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" || exit 0
cleanup_generated_dir() {
local path="$1"
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
return
fi
if git ls-files --error-unmatch "$path" >/dev/null 2>&1; then
if git diff --quiet -- "$path"; then
return
fi
echo "=== git restore --worktree $path ==="
git restore --worktree -- "$path" 2>&1 || true
echo ""
return
fi
if [ -e "$path" ]; then
echo "=== rm -rf $path ==="
rm -rf "$path" 2>&1 || true
echo ""
fi
}
run_required_check() {
local label="$1"
shift
echo "=== $label ==="
if "$@" 2>&1; then
echo ""
return 0
fi
echo ""
return 1
}
echo "Running build, lint, type-check, and security audit..."
echo ""
failures=0
run_required_check "yarn build" yarn build || failures=1
run_required_check "yarn lint" yarn lint || failures=1
run_required_check "yarn type-check" yarn type-check || failures=1
echo "=== yarn audit ==="
yarn audit 2>&1 || true
echo ""
cleanup_generated_dir build
cleanup_generated_dir dist
if [ "$failures" -ne 0 ]; then
if [ "$mode" = "advisory" ]; then
echo "Verification failed, but AGENT_VERIFY_MODE=advisory so the hook is exiting 0."
exit 0
fi
echo "Verification failed."
exit 1
fi
echo "Verification complete."
exit 0
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# afterFileEdit hook: Run yarn install when package.json is changed
# Receives JSON via stdin: {"file_path": "...", "edits": [...]}
input=$(cat)
file_path=$(echo "$input" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:.*"\([^"]*\)"/\1/')
if [ -z "$file_path" ]; then
exit 0
fi
if [ "$file_path" = "package.json" ]; then
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$repo_root" || exit 0
echo "package.json changed - running yarn install to update yarn.lock..."
yarn install
fi
exit 0
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
set -euo pipefail
run_smoke=0
wait_timeout="${AGENT_INIT_TIMEOUT_SECONDS:-60}"
app_url="${AGENT_APP_URL:-http://5chan.localhost:1355}"
while [ "$#" -gt 0 ]; do
case "$1" in
--smoke)
run_smoke=1
;;
*)
echo "Unknown argument: $1" >&2
echo "Usage: ./scripts/agent-init.sh [--smoke]" >&2
exit 1
;;
esac
shift
done
repo_root="$(git rev-parse --show-toplevel)"
log_dir="$repo_root/.playwright-cli"
log_path="${AGENT_START_LOG:-$log_dir/agent-start.log}"
mkdir -p "$log_dir"
cd "$repo_root"
is_server_up() {
curl -fsS "$app_url" >/dev/null 2>&1
}
wait_for_server() {
local started_at
started_at="$(date +%s)"
while [ $(( $(date +%s) - started_at )) -lt "$wait_timeout" ]; do
if is_server_up; then
return 0
fi
sleep 1
done
return 1
}
echo "Repo root: $repo_root"
echo "App URL: $app_url"
if is_server_up; then
echo "Dev server is already reachable."
else
echo "Dev server is not reachable. Starting yarn start..."
nohup yarn start >"$log_path" 2>&1 &
echo "Startup log: $log_path"
if ! wait_for_server; then
echo "Timed out waiting for $app_url" >&2
echo "Last log lines:" >&2
tail -n 40 "$log_path" >&2 || true
exit 1
fi
fi
echo "Dev server is ready."
if [ "$run_smoke" -eq 1 ]; then
echo "Running smoke flow against the live dev server..."
SMOKE_BASE_URL="${app_url%/}/#/" node scripts/smoke-web-app.js
fi
+66
View File
@@ -0,0 +1,66 @@
#!/bin/bash
set -euo pipefail
usage() {
echo "Usage: ./scripts/create-task-worktree.sh <feature|fix|docs|chore> <slug> [base-branch] [worktree-path]"
}
if [ "$#" -lt 2 ]; then
usage >&2
exit 1
fi
task_type="$1"
slug_input="$2"
base_branch="${3:-master}"
case "$task_type" in
feature|fix|docs|chore) ;;
*)
echo "Unsupported task type: $task_type" >&2
usage >&2
exit 1
;;
esac
slug="$(printf '%s' "$slug_input" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g; s/^-+//; s/-+$//')"
if [ -z "$slug" ]; then
echo "Slug must contain at least one letter or number." >&2
exit 1
fi
repo_root="$(git rev-parse --show-toplevel)"
repo_name="$(basename "$repo_root")"
default_worktree_path="$(dirname "$repo_root")/${repo_name}-${slug}"
worktree_path="${4:-$default_worktree_path}"
branch_name="codex/${task_type}/${slug}"
if git show-ref --verify --quiet "refs/heads/$branch_name"; then
echo "Branch already exists: $branch_name" >&2
exit 1
fi
if [ -e "$worktree_path" ]; then
echo "Worktree path already exists: $worktree_path" >&2
exit 1
fi
if git show-ref --verify --quiet "refs/heads/$base_branch"; then
base_ref="$base_branch"
elif git show-ref --verify --quiet "refs/remotes/origin/$base_branch"; then
base_ref="origin/$base_branch"
else
echo "Base branch not found locally or on origin: $base_branch" >&2
exit 1
fi
echo "Creating branch $branch_name from $base_ref"
echo "Creating worktree at $worktree_path"
git worktree add "$worktree_path" -b "$branch_name" "$base_ref"
echo ""
echo "Worktree ready."
echo "Branch: $branch_name"
echo "Path: $worktree_path"