Compare commits
3 commits
main
...
chore/gard
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daf08fe62d | ||
| 9b91c6a5bd | |||
|
|
723167d2f2 |
126 changed files with 8641 additions and 12610 deletions
|
|
@ -1,20 +0,0 @@
|
|||
# Secrets — prevent .env files from being baked into the image
|
||||
.env
|
||||
.env.enc
|
||||
.env.vault
|
||||
.env.vault.enc
|
||||
|
||||
# Version control — .git is huge and not needed in image
|
||||
.git
|
||||
|
||||
# Archives — not needed at runtime
|
||||
*.tar.gz
|
||||
|
||||
# Prometheus data — large, ephemeral data
|
||||
prometheus-data/
|
||||
|
||||
# Compose files — only needed at runtime via volume mount
|
||||
docker-compose.yml
|
||||
|
||||
# Project TOML files — gitignored anyway, won't be in build context
|
||||
projects/*.toml
|
||||
|
|
@ -26,8 +26,8 @@ FORGE_GARDENER_TOKEN= # [SECRET] gardener-bot API token
|
|||
FORGE_VAULT_TOKEN= # [SECRET] vault-bot API token
|
||||
FORGE_SUPERVISOR_TOKEN= # [SECRET] supervisor-bot API token
|
||||
FORGE_PREDICTOR_TOKEN= # [SECRET] predictor-bot API token
|
||||
FORGE_ARCHITECT_TOKEN= # [SECRET] architect-bot API token
|
||||
FORGE_BOT_USERNAMES=dev-bot,review-bot,planner-bot,gardener-bot,vault-bot,supervisor-bot,predictor-bot,architect-bot
|
||||
FORGE_ACTION_TOKEN= # [SECRET] action-bot API token
|
||||
FORGE_BOT_USERNAMES=dev-bot,review-bot,planner-bot,gardener-bot,vault-bot,supervisor-bot,predictor-bot,action-bot
|
||||
|
||||
# ── Backwards compatibility ───────────────────────────────────────────────
|
||||
# If CODEBERG_TOKEN is set but FORGE_TOKEN is not, env.sh falls back to
|
||||
|
|
@ -49,7 +49,7 @@ WOODPECKER_DB_NAME=woodpecker # [CONFIG] Postgres database name
|
|||
|
||||
# ── Vault-only secrets (DO NOT put these in .env) ────────────────────────
|
||||
# These tokens grant access to external systems (GitHub, ClawHub, deploy targets).
|
||||
# They live ONLY in .env.vault.enc and are injected into the ephemeral runner
|
||||
# They live ONLY in .env.vault.enc and are injected into the ephemeral vault-runner
|
||||
# container at fire time (#745). lib/env.sh explicitly unsets them so agents
|
||||
# can never hold them directly — all external actions go through vault dispatch.
|
||||
#
|
||||
|
|
@ -58,7 +58,7 @@ WOODPECKER_DB_NAME=woodpecker # [CONFIG] Postgres database name
|
|||
# (deploy keys) — SSH keys for deployment targets
|
||||
#
|
||||
# To manage vault secrets: disinto secrets edit-vault
|
||||
# (vault redesign in progress: PR-based approval, see #73-#77)
|
||||
# See also: vault/vault-run-action.sh, vault/vault-fire.sh
|
||||
|
||||
# ── Project-specific secrets ──────────────────────────────────────────────
|
||||
# Store all project secrets here so formulas reference env vars, never hardcode.
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -22,9 +22,3 @@ metrics/supervisor-metrics.jsonl
|
|||
.DS_Store
|
||||
dev/ci-fixes-*.json
|
||||
gardener/dust.jsonl
|
||||
|
||||
# Individual encrypted secrets (managed by disinto secrets add)
|
||||
secrets/
|
||||
|
||||
# Pre-built binaries for Docker builds (avoid network calls during build)
|
||||
docker/agents/bin/
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
# 2. Every custom function called by agent scripts is defined in lib/ or the script itself
|
||||
#
|
||||
# Fast (<10s): no network, no tmux, no Claude needed.
|
||||
# Would have caught: kill_tmux_session (renamed), create_agent_session (missing),
|
||||
# read_phase (missing from dev-agent.sh scope)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -19,16 +21,14 @@ FAILED=0
|
|||
# Uses awk instead of grep -Eo for busybox/Alpine compatibility (#296).
|
||||
get_fns() {
|
||||
local f="$1"
|
||||
# Pure-awk implementation: avoids grep/sed cross-platform differences
|
||||
# (BusyBox grep BRE quirks, sed ; separator issues on Alpine).
|
||||
awk '
|
||||
/^[[:space:]]*[a-zA-Z_][a-zA-Z0-9_][a-zA-Z0-9_]*[[:space:]]*[(][)]/ {
|
||||
line = $0
|
||||
gsub(/^[[:space:]]+/, "", line)
|
||||
sub(/[[:space:]]*[(].*/, "", line)
|
||||
print line
|
||||
}
|
||||
' "$f" 2>/dev/null | sort -u || true
|
||||
# Use POSIX character classes and bracket-escaped parens for BusyBox awk
|
||||
# compatibility (BusyBox awk does not expand \t to tab in character classes
|
||||
# and may handle \( differently in ERE patterns).
|
||||
awk '/^[[:space:]]*[a-zA-Z_][a-zA-Z0-9_]+[[:space:]]*[(][)]/ {
|
||||
sub(/^[[:space:]]+/, "")
|
||||
sub(/[[:space:]]*[(][)].*/, "")
|
||||
print
|
||||
}' "$f" 2>/dev/null | sort -u || true
|
||||
}
|
||||
|
||||
# Extract call-position identifiers that look like custom function calls:
|
||||
|
|
@ -86,7 +86,7 @@ while IFS= read -r -d '' f; do
|
|||
printf 'FAIL [syntax] %s\n' "$f"
|
||||
FAILED=1
|
||||
fi
|
||||
done < <(find dev gardener review planner supervisor architect lib vault -name "*.sh" -print0 2>/dev/null)
|
||||
done < <(find dev gardener review planner supervisor lib vault action -name "*.sh" -print0 2>/dev/null)
|
||||
echo "syntax check done"
|
||||
|
||||
# ── 2. Function-resolution check ─────────────────────────────────────────────
|
||||
|
|
@ -97,16 +97,14 @@ echo "=== 2/2 Function resolution ==="
|
|||
#
|
||||
# Included — these are inline-sourced by agent scripts:
|
||||
# lib/env.sh — sourced by every agent (log, forge_api, etc.)
|
||||
# lib/agent-sdk.sh — sourced by SDK agents (agent_run, agent_recover_session)
|
||||
# lib/agent-session.sh — sourced by orchestrators (create_agent_session, monitor_phase_loop, etc.)
|
||||
# lib/ci-helpers.sh — sourced by pollers and review (ci_passed, classify_pipeline_failure, etc.)
|
||||
# lib/load-project.sh — sourced by env.sh when PROJECT_TOML is set
|
||||
# lib/file-action-issue.sh — sourced by gardener-run.sh (file_action_issue)
|
||||
# lib/secret-scan.sh — sourced by file-action-issue.sh (scan_for_secrets, redact_secrets)
|
||||
# lib/formula-session.sh — sourced by formula-driven agents (acquire_cron_lock, check_memory, etc.)
|
||||
# lib/secret-scan.sh — sourced by file-action-issue.sh, phase-handler.sh (scan_for_secrets, redact_secrets)
|
||||
# lib/formula-session.sh — sourced by formula-driven agents (acquire_cron_lock, run_formula_and_monitor, etc.)
|
||||
# lib/mirrors.sh — sourced by merge sites (mirror_push)
|
||||
# lib/guard.sh — sourced by all cron entry points (check_active)
|
||||
# lib/issue-lifecycle.sh — sourced by agents for issue claim/release/block/deps
|
||||
# lib/worktree.sh — sourced by agents for worktree create/recover/cleanup/preserve
|
||||
#
|
||||
# Excluded — not sourced inline by agents:
|
||||
# lib/tea-helpers.sh — sourced conditionally by env.sh (tea_file_issue, etc.); checked standalone below
|
||||
|
|
@ -117,7 +115,7 @@ echo "=== 2/2 Function resolution ==="
|
|||
# If a new lib file is added and sourced by agents, add it to LIB_FUNS below
|
||||
# and add a check_script call for it in the lib files section further down.
|
||||
LIB_FUNS=$(
|
||||
for f in lib/agent-sdk.sh lib/env.sh lib/ci-helpers.sh lib/load-project.sh lib/secret-scan.sh lib/file-action-issue.sh lib/formula-session.sh lib/mirrors.sh lib/guard.sh lib/pr-lifecycle.sh lib/issue-lifecycle.sh lib/worktree.sh; do
|
||||
for f in lib/agent-session.sh lib/env.sh lib/ci-helpers.sh lib/load-project.sh lib/secret-scan.sh lib/file-action-issue.sh lib/formula-session.sh lib/mirrors.sh lib/guard.sh; do
|
||||
if [ -f "$f" ]; then get_fns "$f"; fi
|
||||
done | sort -u
|
||||
)
|
||||
|
|
@ -181,17 +179,15 @@ check_script() {
|
|||
# These are already in LIB_FUNS (their definitions are available to agents),
|
||||
# but this verifies calls *within* each lib file are also resolvable.
|
||||
check_script lib/env.sh lib/mirrors.sh
|
||||
check_script lib/agent-sdk.sh
|
||||
check_script lib/agent-session.sh
|
||||
check_script lib/ci-helpers.sh
|
||||
check_script lib/secret-scan.sh
|
||||
check_script lib/file-action-issue.sh lib/secret-scan.sh
|
||||
check_script lib/tea-helpers.sh lib/secret-scan.sh
|
||||
check_script lib/formula-session.sh
|
||||
check_script lib/formula-session.sh lib/agent-session.sh
|
||||
check_script lib/load-project.sh
|
||||
check_script lib/mirrors.sh lib/env.sh
|
||||
check_script lib/guard.sh
|
||||
check_script lib/pr-lifecycle.sh
|
||||
check_script lib/issue-lifecycle.sh lib/secret-scan.sh
|
||||
|
||||
# Standalone lib scripts (not sourced by agents; run directly or as services).
|
||||
# Still checked for function resolution against LIB_FUNS + own definitions.
|
||||
|
|
@ -199,19 +195,26 @@ check_script lib/ci-debug.sh
|
|||
check_script lib/parse-deps.sh
|
||||
|
||||
# Agent scripts — list cross-sourced files where function scope flows across files.
|
||||
check_script dev/dev-agent.sh
|
||||
# dev-agent.sh sources phase-handler.sh; phase-handler.sh calls helpers defined in dev-agent.sh.
|
||||
check_script dev/dev-agent.sh dev/phase-handler.sh
|
||||
check_script dev/phase-handler.sh dev/dev-agent.sh lib/secret-scan.sh
|
||||
check_script dev/dev-poll.sh
|
||||
check_script dev/phase-test.sh
|
||||
check_script gardener/gardener-run.sh
|
||||
check_script review/review-pr.sh lib/agent-sdk.sh
|
||||
check_script review/review-pr.sh lib/agent-session.sh
|
||||
check_script review/review-poll.sh
|
||||
check_script planner/planner-run.sh lib/formula-session.sh
|
||||
check_script planner/planner-run.sh lib/agent-session.sh lib/formula-session.sh
|
||||
check_script supervisor/supervisor-poll.sh
|
||||
check_script supervisor/update-prompt.sh
|
||||
check_script vault/vault-agent.sh
|
||||
check_script vault/vault-fire.sh
|
||||
check_script vault/vault-poll.sh
|
||||
check_script vault/vault-reject.sh
|
||||
check_script action/action-poll.sh
|
||||
check_script action/action-agent.sh dev/phase-handler.sh
|
||||
check_script supervisor/supervisor-run.sh
|
||||
check_script supervisor/preflight.sh
|
||||
check_script predictor/predictor-run.sh
|
||||
check_script architect/architect-run.sh
|
||||
|
||||
echo "function resolution check done"
|
||||
|
||||
|
|
|
|||
|
|
@ -8,19 +8,6 @@
|
|||
when:
|
||||
event: [push, pull_request]
|
||||
|
||||
# Override default clone to authenticate against Forgejo using FORGE_TOKEN.
|
||||
# Required because Forgejo is configured with REQUIRE_SIGN_IN, so anonymous
|
||||
# git clones fail with exit code 128. FORGE_TOKEN is injected globally via
|
||||
# WOODPECKER_ENVIRONMENT in docker-compose.yml (generated by lib/generators.sh).
|
||||
clone:
|
||||
git:
|
||||
image: alpine/git
|
||||
commands:
|
||||
- AUTH_URL=$(printf '%s' "$CI_REPO_CLONE_URL" | sed "s|://|://token:$FORGE_TOKEN@|")
|
||||
- git clone --depth 1 "$AUTH_URL" .
|
||||
- git fetch --depth 1 origin "$CI_COMMIT_REF"
|
||||
- git checkout FETCH_HEAD
|
||||
|
||||
steps:
|
||||
- name: shellcheck
|
||||
image: koalaman/shellcheck-alpine:stable
|
||||
|
|
@ -29,8 +16,6 @@ steps:
|
|||
|
||||
- name: agent-smoke
|
||||
image: alpine:3
|
||||
when:
|
||||
event: pull_request
|
||||
commands:
|
||||
- apk add --no-cache bash
|
||||
- bash .woodpecker/agent-smoke.sh
|
||||
|
|
|
|||
|
|
@ -179,16 +179,9 @@ def collect_findings(root):
|
|||
Returns ``(ap_hits, dup_groups)`` with file paths relative to *root*.
|
||||
"""
|
||||
root = Path(root)
|
||||
# Skip architect scripts for duplicate detection (stub formulas, see #99)
|
||||
EXCLUDED_SUFFIXES = ("architect/architect-run.sh",)
|
||||
|
||||
def is_excluded(p):
|
||||
"""Check if path should be excluded by suffix match."""
|
||||
return p.suffix == ".sh" and ".git" not in p.parts and any(
|
||||
str(p).endswith(suffix) for suffix in EXCLUDED_SUFFIXES
|
||||
)
|
||||
|
||||
sh_files = sorted(p for p in root.rglob("*.sh") if not is_excluded(p))
|
||||
sh_files = sorted(
|
||||
p for p in root.rglob("*.sh") if ".git" not in p.parts
|
||||
)
|
||||
|
||||
ap_hits = check_anti_patterns(sh_files)
|
||||
dup_groups = check_duplicates(sh_files)
|
||||
|
|
@ -245,77 +238,9 @@ def print_duplicates(groups, label=""):
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> int:
|
||||
# Skip architect scripts for duplicate detection (stub formulas, see #99)
|
||||
EXCLUDED_SUFFIXES = ("architect/architect-run.sh",)
|
||||
|
||||
def is_excluded(p):
|
||||
"""Check if path should be excluded by suffix match."""
|
||||
return p.suffix == ".sh" and ".git" not in p.parts and any(
|
||||
str(p).endswith(suffix) for suffix in EXCLUDED_SUFFIXES
|
||||
)
|
||||
|
||||
sh_files = sorted(p for p in Path(".").rglob("*.sh") if not is_excluded(p))
|
||||
|
||||
# Standard patterns that are intentionally repeated across formula-driven agents
|
||||
# These are not copy-paste violations but the expected structure
|
||||
ALLOWED_HASHES = {
|
||||
# Standard agent header: shebang, set -euo pipefail, directory resolution
|
||||
"c93baa0f19d6b9ba271428bf1cf20b45": "Standard agent header (set -euo pipefail, SCRIPT_DIR, FACTORY_ROOT)",
|
||||
# formula_prepare_profile_context followed by scratch context reading
|
||||
"eaa735b3598b7b73418845ab00d8aba5": "Standard .profile context setup (formula_prepare_profile_context + SCRATCH_CONTEXT)",
|
||||
# Standard prompt template: GRAPH_SECTION, SCRATCH_CONTEXT, FORMULA_CONTENT, SCRATCH_INSTRUCTION
|
||||
"2653705045fdf65072cccfd16eb04900": "Standard prompt template (GRAPH_SECTION, SCRATCH_CONTEXT, FORMULA_CONTENT)",
|
||||
"93726a3c799b72ed2898a55552031921": "Standard prompt template continuation (SCRATCH_CONTEXT, FORMULA_CONTENT, SCRATCH_INSTRUCTION)",
|
||||
"c11eaaacab69c9a2d3c38c75215eca84": "Standard prompt template end (FORMULA_CONTENT, SCRATCH_INSTRUCTION)",
|
||||
# install_project_crons function in entrypoint.sh and entrypoint-llama.sh (intentional duplicate)
|
||||
"007e1390498374c68ab5d66aa6d277b2": "install_project_crons function in entrypoints (window 007e1390)",
|
||||
"04143957d4c63e8a16ac28bddaff589b": "install_project_crons function in entrypoints (window 04143957)",
|
||||
"076a19221cde674b2fce20a17292fa78": "install_project_crons function in entrypoints (window 076a1922)",
|
||||
"0d498287626e105f16b24948aed53584": "install_project_crons function in entrypoints (window 0d498287)",
|
||||
"137b746928011acd758c7a9c690810b2": "install_project_crons function in entrypoints (window 137b7469)",
|
||||
"287d33d98d21e3e07e0869e56ad94527": "install_project_crons function in entrypoints (window 287d33d9)",
|
||||
"325a3d54a15e59d333ec2a20c062cc8c": "install_project_crons function in entrypoints (window 325a3d54)",
|
||||
"34e1943d5738f540d67c5c6bd3e60b20": "install_project_crons function in entrypoints (window 34e1943d)",
|
||||
"3dabd19698f9705b05376c38042ccce8": "install_project_crons function in entrypoints (window 3dabd196)",
|
||||
"446b420f7f9821a2553bc4995d1fac25": "install_project_crons function in entrypoints (window 446b420f)",
|
||||
"4826cf4896b792368c7b4d77573d0f8b": "install_project_crons function in entrypoints (window 4826cf48)",
|
||||
"4e564d3bbda0ef33962af6042736dc1e": "install_project_crons function in entrypoints (window 4e564d3b)",
|
||||
"5a3d92b22e5d5bca8cce17d581ac6803": "install_project_crons function in entrypoints (window 5a3d92b2)",
|
||||
"63c20c5a31cf5e08f3a901ddf6db98af": "install_project_crons function in entrypoints (window 63c20c5a)",
|
||||
"77547751325562fac397bbfd3a21c88e": "install_project_crons function in entrypoints (window 77547751)",
|
||||
"80bdff63e54b4a260043d264b83d8eb0": "install_project_crons function in entrypoints (window 80bdff63)",
|
||||
"84e55706393f731b293890dd6d830316": "install_project_crons function in entrypoints (window 84e55706)",
|
||||
"85f8a9d029ee9efecca73fd30449ccf4": "install_project_crons function in entrypoints (window 85f8a9d0)",
|
||||
"86e28dae676c905c5aa0035128e20e46": "install_project_crons function in entrypoints (window 86e28dae)",
|
||||
"a222b73bcd6a57adb2315726e81ab6cf": "install_project_crons function in entrypoints (window a222b73b)",
|
||||
"abd6c7efe66f533c48c883c2a6998886": "install_project_crons function in entrypoints (window abd6c7ef)",
|
||||
"bcfeb67ce4939181330afea4949a95cf": "install_project_crons function in entrypoints (window bcfeb67c)",
|
||||
"c1248c98f978c48e4a1e5009a1440917": "install_project_crons function in entrypoints (window c1248c98)",
|
||||
"c40571185b3306345ecf9ac33ab352a6": "install_project_crons function in entrypoints (window c4057118)",
|
||||
"c566639b237036a7a385982274d3d271": "install_project_crons function in entrypoints (window c566639b)",
|
||||
"d9cd2f3d874c32366d577ea0d334cd1a": "install_project_crons function in entrypoints (window d9cd2f3d)",
|
||||
"df4d3e905b12f2c68b206e45dddf9214": "install_project_crons function in entrypoints (window df4d3e90)",
|
||||
"e8e65ccf867fc6cbe49695ecdce2518e": "install_project_crons function in entrypoints (window e8e65ccf)",
|
||||
"eb8b298f06cda4359cc171206e0014bf": "install_project_crons function in entrypoints (window eb8b298f)",
|
||||
"ecdf0daa2f2845359a6a4aa12d327246": "install_project_crons function in entrypoints (window ecdf0daa)",
|
||||
"eeac93b2fba4de4589d36ca20845ec9f": "install_project_crons function in entrypoints (window eeac93b2)",
|
||||
"f08a7139db9c96cd3526549c499c0332": "install_project_crons function in entrypoints (window f08a7139)",
|
||||
"f0917809bdf28ff93fff0749e7e7fea0": "install_project_crons function in entrypoints (window f0917809)",
|
||||
"f0e4101f9b90c2fa921e088057a96db7": "install_project_crons function in entrypoints (window f0e4101f)",
|
||||
# Structural end-of-while-loop+case pattern: `return 1 ;; esac done }`
|
||||
# Appears in stack_lock_acquire (lib/stack-lock.sh) and lib/pr-lifecycle.sh
|
||||
"29d4f34b703f44699237713cc8d8065b": "Structural end-of-while-loop+case (return 1, esac, done, closing brace)",
|
||||
# Forgejo org-creation API call pattern shared between forge-setup.sh and ops-setup.sh
|
||||
# Extracted from bin/disinto (not a .sh file, excluded from prior scans) into lib/forge-setup.sh
|
||||
"059b11945140c172465f9126b829ed7f": "Forgejo org-creation curl pattern (forge-setup.sh + ops-setup.sh)",
|
||||
# Docker compose environment block for agents service (generators.sh + hire-agent.sh)
|
||||
# Intentional duplicate - both generate the same docker-compose.yml template
|
||||
"8066210169a462fe565f18b6a26a57e0": "Docker compose environment block (generators.sh + hire-agent.sh)",
|
||||
"fd978fcd726696e0f280eba2c5198d50": "Docker compose environment block continuation (generators.sh + hire-agent.sh)",
|
||||
"e2760ccc2d4b993a3685bd8991594eb2": "Docker compose env_file + depends_on block (generators.sh + hire-agent.sh)",
|
||||
# The hash shown in output is 161a80f7 - need to match exactly what the script finds
|
||||
"161a80f7296d6e9d45895607b7f5b9c9": "Docker compose env_file + depends_on block (generators.sh + hire-agent.sh)",
|
||||
}
|
||||
sh_files = sorted(
|
||||
p for p in Path(".").rglob("*.sh") if ".git" not in p.parts
|
||||
)
|
||||
|
||||
if not sh_files:
|
||||
print("No .sh files found.")
|
||||
|
|
@ -351,13 +276,8 @@ def main() -> int:
|
|||
|
||||
# Duplicate diff: key by content hash
|
||||
base_dup_hashes = {g[0] for g in base_dups}
|
||||
# Filter out allowed standard patterns that are intentionally repeated
|
||||
new_dups = [
|
||||
g for g in cur_dups
|
||||
if g[0] not in base_dup_hashes and g[0] not in ALLOWED_HASHES
|
||||
]
|
||||
# Also filter allowed hashes from pre_dups for reporting
|
||||
pre_dups = [g for g in cur_dups if g[0] in base_dup_hashes and g[0] not in ALLOWED_HASHES]
|
||||
new_dups = [g for g in cur_dups if g[0] not in base_dup_hashes]
|
||||
pre_dups = [g for g in cur_dups if g[0] in base_dup_hashes]
|
||||
|
||||
# Report pre-existing as info
|
||||
if pre_ap or pre_dups:
|
||||
|
|
|
|||
|
|
@ -1,19 +1,31 @@
|
|||
# .woodpecker/smoke-init.yml — End-to-end smoke test for disinto init
|
||||
#
|
||||
# Uses the Forgejo image directly (not as a service) so we have CLI
|
||||
# access to set up Forgejo and create the bootstrap admin user.
|
||||
# Then runs disinto init --bare --yes against the local Forgejo instance.
|
||||
#
|
||||
# Forgejo refuses to run as root, so all forgejo commands use su-exec
|
||||
# to run as the 'git' user (pre-created in the Forgejo Docker image).
|
||||
|
||||
when:
|
||||
- event: pull_request
|
||||
path:
|
||||
- "bin/disinto"
|
||||
- "lib/load-project.sh"
|
||||
- "lib/env.sh"
|
||||
- "lib/generators.sh"
|
||||
- "tests/**"
|
||||
- ".woodpecker/smoke-init.yml"
|
||||
event: [push, pull_request]
|
||||
|
||||
steps:
|
||||
- name: smoke-init
|
||||
image: python:3-alpine
|
||||
image: codeberg.org/forgejo/forgejo:11.0
|
||||
environment:
|
||||
SMOKE_FORGE_URL: http://localhost:3000
|
||||
commands:
|
||||
- apk add --no-cache bash curl jq git coreutils
|
||||
- python3 tests/mock-forgejo.py & echo $! > /tmp/mock-forgejo.pid
|
||||
- sleep 2
|
||||
# Install test dependencies (Alpine-based image)
|
||||
- apk add --no-cache bash curl jq python3 git >/dev/null 2>&1
|
||||
# Set up Forgejo data directories and config (owned by git user)
|
||||
- mkdir -p /data/gitea/conf /data/gitea/repositories /data/gitea/lfs /data/gitea/log /data/git/.ssh /data/ssh
|
||||
- printf '[database]\nDB_TYPE = sqlite3\nPATH = /data/gitea/forgejo.db\n\n[server]\nHTTP_PORT = 3000\nROOT_URL = http://localhost:3000/\nLFS_START_SERVER = false\n\n[security]\nINSTALL_LOCK = true\n\n[service]\nDISABLE_REGISTRATION = true\n' > /data/gitea/conf/app.ini
|
||||
- chown -R git:git /data
|
||||
# Start Forgejo as git user in background and wait for API
|
||||
- su-exec git forgejo web --config /data/gitea/conf/app.ini &
|
||||
- for i in $(seq 1 30); do curl -sf http://localhost:3000/api/v1/version >/dev/null 2>&1 && break; sleep 1; done
|
||||
# Create bootstrap admin user via CLI
|
||||
- su-exec git forgejo admin user create --admin --username setup-admin --password "SetupPass-789xyz" --email "setup-admin@smoke.test" --must-change-password=false --config /data/gitea/conf/app.ini
|
||||
# Run the smoke test (as root is fine — only forgejo binary needs git user)
|
||||
- bash tests/smoke-init.sh
|
||||
- kill $(cat /tmp/mock-forgejo.pid) 2>/dev/null || true
|
||||
|
|
|
|||
60
AGENTS.md
60
AGENTS.md
|
|
@ -1,36 +1,30 @@
|
|||
<!-- last-reviewed: ac2beac361503c8712ecfc72be0401b5968cce4e -->
|
||||
<!-- last-reviewed: 9b91c6a5bdd83513ca262dd468ea14db675971c1 -->
|
||||
# Disinto — Agent Instructions
|
||||
|
||||
## What this repo is
|
||||
|
||||
Disinto is an autonomous code factory. It manages seven agents (dev, review,
|
||||
gardener, supervisor, planner, predictor, architect) that pick up issues from
|
||||
forge, implement them, review PRs, plan from the vision, and keep the system
|
||||
healthy — all via cron and `claude -p`. The dispatcher executes formula-based
|
||||
operational tasks.
|
||||
Disinto is an autonomous code factory. It manages eight agents (dev, review,
|
||||
gardener, supervisor, planner, predictor, action, vault) that pick up issues from forge,
|
||||
implement them, review PRs, plan from the vision, gate dangerous actions, and
|
||||
keep the system healthy — all via cron and `claude -p`.
|
||||
|
||||
Each agent has a `.profile` repository on Forgejo that stores lessons learned
|
||||
from prior sessions, providing continuous improvement across runs.
|
||||
|
||||
> **Note:** The vault is being redesigned as a PR-based approval workflow on the
|
||||
> ops repo (see issues #73-#77). See [docs/VAULT.md](docs/VAULT.md) for details. Old vault scripts are being removed.
|
||||
|
||||
See `README.md` for the full architecture and `disinto-factory/SKILL.md` for setup.
|
||||
See `README.md` for the full architecture and `BOOTSTRAP.md` for setup.
|
||||
|
||||
## Directory layout
|
||||
|
||||
```
|
||||
disinto/ (code repo)
|
||||
├── dev/ dev-poll.sh, dev-agent.sh, phase-test.sh — issue implementation
|
||||
├── dev/ dev-poll.sh, dev-agent.sh, phase-handler.sh — issue implementation
|
||||
├── review/ review-poll.sh, review-pr.sh — PR review
|
||||
├── gardener/ gardener-run.sh — direct cron executor for run-gardener formula
|
||||
├── predictor/ predictor-run.sh — daily cron executor for run-predictor formula
|
||||
├── planner/ planner-run.sh — direct cron executor for run-planner formula
|
||||
├── supervisor/ supervisor-run.sh — formula-driven health monitoring (cron wrapper)
|
||||
│ preflight.sh — pre-flight data collection for supervisor formula
|
||||
├── architect/ architect-run.sh — strategic decomposition of vision into sprints
|
||||
├── vault/ vault-env.sh — shared env setup (vault redesign in progress, see #73-#77)
|
||||
├── lib/ env.sh, agent-sdk.sh, ci-helpers.sh, ci-debug.sh, load-project.sh, parse-deps.sh, guard.sh, mirrors.sh, pr-lifecycle.sh, issue-lifecycle.sh, worktree.sh, formula-session.sh, stack-lock.sh, forge-setup.sh, forge-push.sh, ops-setup.sh, ci-setup.sh, generators.sh, hire-agent.sh, release.sh, build-graph.py
|
||||
│ supervisor-poll.sh — legacy bash orchestrator (superseded)
|
||||
├── vault/ vault-poll.sh, vault-agent.sh, vault-fire.sh — action gating + procurement
|
||||
├── action/ action-poll.sh, action-agent.sh — operational task execution
|
||||
├── lib/ env.sh, agent-session.sh, ci-helpers.sh, ci-debug.sh, load-project.sh, parse-deps.sh, guard.sh, mirrors.sh, build-graph.py
|
||||
├── projects/ *.toml.example — templates; *.toml — local per-box config (gitignored)
|
||||
├── formulas/ Issue templates (TOML specs for multi-step agent tasks)
|
||||
└── docs/ Protocol docs (PHASE-PROTOCOL.md, EVIDENCE-ARCHITECTURE.md)
|
||||
|
|
@ -41,6 +35,9 @@ disinto-ops/ (ops repo — {project}-ops)
|
|||
│ ├── approved/ approved vault items
|
||||
│ ├── fired/ executed vault items
|
||||
│ └── rejected/ rejected vault items
|
||||
├── journal/
|
||||
│ ├── planner/ daily planning logs
|
||||
│ └── supervisor/ operational health logs
|
||||
├── knowledge/ shared agent knowledge + best practices
|
||||
├── evidence/ engagement data, experiment results
|
||||
├── portfolio.md addressables + observables
|
||||
|
|
@ -48,13 +45,10 @@ disinto-ops/ (ops repo — {project}-ops)
|
|||
└── RESOURCES.md accounts, tokens (refs), infra inventory
|
||||
```
|
||||
|
||||
> **Note:** Journal directories (`journal/planner/` and `journal/supervisor/`) have been removed from the ops repo. Agent journals are now stored in each agent's `.profile` repo on Forgejo.
|
||||
|
||||
## Agent .profile Model
|
||||
|
||||
Each agent has a `.profile` repository on Forgejo storing `knowledge/lessons-learned.md` (injected into each session prompt) and `journal/` reflection entries (digested into lessons). Pre-session: `formula_prepare_profile_context()` loads lessons. Post-session: `profile_write_journal` records reflections. See `lib/profile.sh`.
|
||||
|
||||
> **Terminology note:** "Formulas" are TOML issue templates in `formulas/` that orchestrate multi-step agent tasks. Distinct from "processes" in `docs/EVIDENCE-ARCHITECTURE.md`.
|
||||
> **Terminology note:** "Formulas" in this repo are TOML issue templates in `formulas/` that
|
||||
> orchestrate multi-step agent tasks (e.g., `run-gardener.toml`, `run-planner.toml`). This is
|
||||
> distinct from "processes" described in `docs/EVIDENCE-ARCHITECTURE.md`, which are measurement
|
||||
> and mutation pipelines that read external platforms and write structured evidence to git.
|
||||
|
||||
## Tech stack
|
||||
|
||||
|
|
@ -96,10 +90,8 @@ bash dev/phase-test.sh
|
|||
| Supervisor | `supervisor/` | Health monitoring | [supervisor/AGENTS.md](supervisor/AGENTS.md) |
|
||||
| Planner | `planner/` | Strategic planning | [planner/AGENTS.md](planner/AGENTS.md) |
|
||||
| Predictor | `predictor/` | Infrastructure pattern detection | [predictor/AGENTS.md](predictor/AGENTS.md) |
|
||||
| Architect | `architect/` | Strategic decomposition | [architect/AGENTS.md](architect/AGENTS.md) |
|
||||
|
||||
> **Vault:** Being redesigned as a PR-based approval workflow (issues #73-#77).
|
||||
> See [docs/VAULT.md](docs/VAULT.md) for the vault PR workflow details.
|
||||
| Action | `action/` | Operational task execution | [action/AGENTS.md](action/AGENTS.md) |
|
||||
| Vault | `vault/` | Action gating + resource procurement | [vault/AGENTS.md](vault/AGENTS.md) |
|
||||
|
||||
See [lib/AGENTS.md](lib/AGENTS.md) for the full shared helper reference.
|
||||
|
||||
|
|
@ -116,16 +108,14 @@ Issues flow: `backlog` → `in-progress` → PR → CI → review → merge →
|
|||
| `backlog` | Issue is queued for implementation. Dev-poll picks the first ready one. | Planner, gardener, humans |
|
||||
| `priority` | Queue tier above plain backlog. Issues with both `priority` and `backlog` are picked before plain `backlog` issues. FIFO within each tier. | Planner, humans |
|
||||
| `in-progress` | Dev-agent is actively working on this issue. Only one issue per project is in-progress at a time. | dev-agent.sh (claims issue) |
|
||||
| `blocked` | Issue is stuck — agent session failed, crashed, timed out, or CI exhausted. Diagnostic comment on the issue has details. Also used for unmet dependencies. | dev-agent.sh, dev-poll.sh (on failure) |
|
||||
| `blocked` | Issue is stuck — agent session failed, crashed, timed out, or CI exhausted. Diagnostic comment on the issue has details. Also used for unmet dependencies. | dev-agent.sh, action-agent.sh, dev-poll.sh (on failure) |
|
||||
| `tech-debt` | Pre-existing issue flagged by AI reviewer, not introduced by a PR. | review-pr.sh (auto-created follow-ups) |
|
||||
| `underspecified` | Dev-agent refused the issue as too large or vague. | dev-poll.sh (on preflight `too_large`), dev-agent.sh (on mid-run `too_large` refusal) |
|
||||
| `bug-report` | Issue describes user-facing broken behavior with reproduction steps. Separate triage track for reproduction automation. | Gardener (bug-report detection in grooming) |
|
||||
| `in-triage` | Bug reproduced but root cause not obvious — triage agent investigates. Set alongside `bug-report`. | reproduce-agent (when reproduction succeeds but cause unclear) |
|
||||
| `rejected` | Issue formally rejected — cannot reproduce, out of scope, or invalid. | reproduce-agent, humans |
|
||||
| `vision` | Goal anchors — high-level objectives from VISION.md. | Planner, humans |
|
||||
| `prediction/unreviewed` | Unprocessed prediction filed by predictor. | predictor-run.sh |
|
||||
| `prediction/dismissed` | Prediction triaged as DISMISS — planner disagrees, closed with reason. | Planner (triage-predictions step) |
|
||||
| `prediction/actioned` | Prediction promoted or dismissed by planner. | Planner (triage-predictions step) |
|
||||
| `action` | Operational task for the action-agent to execute via formula. | Planner, humans |
|
||||
|
||||
### Dependency conventions
|
||||
|
||||
|
|
@ -170,12 +160,12 @@ Humans write these. Agents read and enforce them.
|
|||
|
||||
| ID | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| AD-001 | Nervous system runs from cron, not PR-based actions. | Planner, predictor, gardener, supervisor run directly via `*-run.sh`. They create work, they don't become work. (See PR #474 revert.) |
|
||||
| AD-001 | Nervous system runs from cron, not action issues. | Planner, predictor, gardener, supervisor run directly via `*-run.sh`. They create work, they don't become work. (See PR #474 revert.) |
|
||||
| AD-002 | Single-threaded pipeline per project. | One dev issue at a time. No new work while a PR awaits CI or review. Prevents merge conflicts and keeps context clear. |
|
||||
| AD-003 | The runtime creates and destroys, the formula preserves. | Runtime manages worktrees/sessions/temp. Formulas commit knowledge to git before signaling done. |
|
||||
| AD-004 | Event-driven > polling > fixed delays. | Never `waitForTimeout` or hardcoded sleep. Use phase files, webhooks, or poll loops with backoff. |
|
||||
| AD-005 | Secrets via env var indirection, never in issue bodies. | Issue bodies become code. Agent secrets go in `.env.enc`, vault secrets in `.env.vault.enc` (both SOPS-encrypted). Referenced as `$VAR_NAME`. Runner gets only vault secrets; agents get only agent secrets. |
|
||||
| AD-006 | External actions go through vault dispatch, never direct. | Agents build addressables; only the vault exercises them (publishes, deploys, posts). Tokens for external systems (`GITHUB_TOKEN`, `CLAWHUB_TOKEN`, deploy keys) live only in `.env.vault.enc` and are injected into the ephemeral runner container. `lib/env.sh` unsets them so agents never hold them. PRs with direct external actions without vault dispatch get REQUEST_CHANGES. (Vault redesign in progress: PR-based approval on ops repo, see #73-#77) |
|
||||
| AD-005 | Secrets via env var indirection, never in issue bodies. | Issue bodies become code. Agent secrets go in `.env.enc`, vault secrets in `.env.vault.enc` (both SOPS-encrypted). Referenced as `$VAR_NAME`. Vault-runner gets only vault secrets; agents get only agent secrets. |
|
||||
| AD-006 | External actions go through vault dispatch, never direct. | Agents build addressables; only the vault exercises them (publishes, deploys, posts). Tokens for external systems (`GITHUB_TOKEN`, `CLAWHUB_TOKEN`, deploy keys) live only in `.env.vault.enc` and are injected into the ephemeral vault-runner container. `lib/env.sh` unsets them so agents never hold them. PRs with direct external actions without vault dispatch get REQUEST_CHANGES. |
|
||||
|
||||
**Who enforces what:**
|
||||
- **Gardener** checks open backlog issues against ADs during grooming; closes violations with a comment referencing the AD number.
|
||||
|
|
|
|||
460
BOOTSTRAP.md
Normal file
460
BOOTSTRAP.md
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
# Bootstrapping a New Project
|
||||
|
||||
How to point disinto at a new target project and get all agents running.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before starting, ensure you have:
|
||||
|
||||
- [ ] A **git repo** (GitHub, Codeberg, or any URL) with at least one issue labeled `backlog`
|
||||
- [ ] A **Woodpecker CI** pipeline (`.woodpecker/` dir with at least one `.yml`)
|
||||
- [ ] **Docker** installed (for local Forgejo provisioning) — or a running Forgejo instance
|
||||
- [ ] A **local clone** of the target repo on the same machine as disinto
|
||||
- [ ] `claude` CLI installed and authenticated (`claude --version`)
|
||||
- [ ] `tmux` installed (`tmux -V`) — required for persistent dev sessions (issue #80+)
|
||||
|
||||
## Quick Start
|
||||
|
||||
The fastest path is `disinto init`, which provisions a local Forgejo instance, creates bot users and tokens, clones the repo, and sets up cron — all in one command:
|
||||
|
||||
```bash
|
||||
disinto init https://github.com/org/repo
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Start a local Forgejo instance via Docker (at `http://localhost:3000`)
|
||||
2. Create admin + bot users (dev-bot, review-bot) with API tokens
|
||||
3. Create the repo on Forgejo and push your code
|
||||
4. Generate a `projects/<name>.toml` config
|
||||
5. Create standard labels (backlog, in-progress, blocked, etc.)
|
||||
6. Install cron entries for the agents
|
||||
|
||||
No external accounts or tokens needed.
|
||||
|
||||
## 1. Secret Management (SOPS + age)
|
||||
|
||||
Disinto encrypts secrets at rest using [SOPS](https://github.com/getsops/sops) with [age](https://age-encryption.org/) encryption. When `sops` and `age` are installed, `disinto init` automatically:
|
||||
|
||||
1. Generates an age key at `~/.config/sops/age/keys.txt` (if none exists)
|
||||
2. Creates `.sops.yaml` pinning the age public key
|
||||
3. Encrypts all secrets into `.env.enc` (safe to commit)
|
||||
4. Removes the plaintext `.env`
|
||||
|
||||
**Install the tools:**
|
||||
|
||||
```bash
|
||||
# age (key generation)
|
||||
apt install age # Debian/Ubuntu
|
||||
brew install age # macOS
|
||||
|
||||
# sops (encryption/decryption)
|
||||
# Download from https://github.com/getsops/sops/releases
|
||||
```
|
||||
|
||||
**The age private key** at `~/.config/sops/age/keys.txt` is the single file that must be protected. Back it up securely — without it, `.env.enc` cannot be decrypted. LUKS disk encryption on the VPS protects this key at rest.
|
||||
|
||||
**Managing secrets after setup:**
|
||||
|
||||
```bash
|
||||
disinto secrets edit # Opens .env.enc in $EDITOR, re-encrypts on save
|
||||
disinto secrets show # Prints decrypted secrets (for debugging)
|
||||
disinto secrets migrate # Converts existing plaintext .env -> .env.enc
|
||||
```
|
||||
|
||||
**Fallback:** If `sops`/`age` are not installed, `disinto init` writes secrets to a plaintext `.env` file with a warning. All agents load secrets transparently — `lib/env.sh` checks for `.env.enc` first, then falls back to `.env`.
|
||||
|
||||
## 2. Configure `.env`
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Fill in:
|
||||
|
||||
```bash
|
||||
# ── Forge (auto-populated by disinto init) ─────────────────
|
||||
FORGE_URL=http://localhost:3000 # local Forgejo instance
|
||||
FORGE_TOKEN= # dev-bot token (auto-generated)
|
||||
FORGE_REVIEW_TOKEN= # review-bot token (auto-generated)
|
||||
|
||||
# ── Woodpecker CI ───────────────────────────────────────────
|
||||
WOODPECKER_TOKEN=tok_xxxxxxxx
|
||||
WOODPECKER_SERVER=http://localhost:8000
|
||||
# WOODPECKER_REPO_ID — now per-project, set in projects/*.toml [ci] section
|
||||
|
||||
# Woodpecker Postgres (for direct pipeline queries)
|
||||
WOODPECKER_DB_PASSWORD=secret
|
||||
WOODPECKER_DB_USER=woodpecker
|
||||
WOODPECKER_DB_HOST=127.0.0.1
|
||||
WOODPECKER_DB_NAME=woodpecker
|
||||
|
||||
# ── Tuning ──────────────────────────────────────────────────
|
||||
CLAUDE_TIMEOUT=7200 # seconds per Claude invocation
|
||||
```
|
||||
|
||||
### Backwards compatibility
|
||||
|
||||
If you have an existing deployment using `CODEBERG_TOKEN` / `REVIEW_BOT_TOKEN` in `.env`, those still work — `env.sh` falls back to the old names automatically. No migration needed.
|
||||
|
||||
## 3. Configure Project TOML
|
||||
|
||||
Each project needs a `projects/<name>.toml` file with box-specific settings
|
||||
(absolute paths, Woodpecker CI IDs, forge URL). These files are
|
||||
**gitignored** — they are local installation config, not shared code.
|
||||
|
||||
To create one:
|
||||
|
||||
```bash
|
||||
# Automatic — generates TOML, clones repo, sets up cron:
|
||||
disinto init https://github.com/org/repo
|
||||
|
||||
# Manual — copy a template and fill in your values:
|
||||
cp projects/myproject.toml.example projects/myproject.toml
|
||||
vim projects/myproject.toml
|
||||
```
|
||||
|
||||
The `forge_url` field in the TOML tells all agents where to find the forge API:
|
||||
|
||||
```toml
|
||||
name = "myproject"
|
||||
repo = "org/myproject"
|
||||
forge_url = "http://localhost:3000"
|
||||
```
|
||||
|
||||
The repo ships `projects/*.toml.example` templates showing the expected
|
||||
structure. See any `.toml.example` file for the full field reference.
|
||||
|
||||
## 4. Claude Code Global Settings
|
||||
|
||||
Configure `~/.claude/settings.json` with **only** permissions and `skipDangerousModePermissionPrompt`. Do not add hooks to the global settings — `agent-session.sh` injects per-worktree hooks automatically.
|
||||
|
||||
Match the configuration from harb-staging exactly. The file should contain only permission grants and the dangerous-mode flag:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"..."
|
||||
]
|
||||
},
|
||||
"skipDangerousModePermissionPrompt": true
|
||||
}
|
||||
```
|
||||
|
||||
### Seed `~/.claude.json`
|
||||
|
||||
Run `claude --dangerously-skip-permissions` once interactively to create `~/.claude.json`. This file must exist before cron-driven agents can run.
|
||||
|
||||
```bash
|
||||
claude --dangerously-skip-permissions
|
||||
# Exit after it initializes successfully
|
||||
```
|
||||
|
||||
## 5. File Ownership
|
||||
|
||||
Everything under `/home/debian` must be owned by `debian:debian`. Root-owned files cause permission errors when agents run as the `debian` user.
|
||||
|
||||
```bash
|
||||
chown -R debian:debian /home/debian/harb /home/debian/dark-factory
|
||||
```
|
||||
|
||||
Verify no root-owned files exist in agent temp directories:
|
||||
|
||||
```bash
|
||||
# These should return nothing
|
||||
find /tmp/dev-* /tmp/harb-* /tmp/review-* -not -user debian 2>/dev/null
|
||||
```
|
||||
|
||||
## 5b. Woodpecker CI + Forgejo Integration
|
||||
|
||||
`disinto init` automatically configures Woodpecker to use the local Forgejo instance as its forge backend if `WOODPECKER_SERVER` is set in `.env`. This includes:
|
||||
|
||||
1. Creating an OAuth2 application on Forgejo for Woodpecker
|
||||
2. Writing `WOODPECKER_FORGEJO_*` env vars to `.env`
|
||||
3. Activating the repo in Woodpecker
|
||||
|
||||
### Manual setup (if Woodpecker runs outside of `disinto init`)
|
||||
|
||||
If you manage Woodpecker separately, configure these env vars in its server config:
|
||||
|
||||
```bash
|
||||
WOODPECKER_FORGEJO=true
|
||||
WOODPECKER_FORGEJO_URL=http://localhost:3000
|
||||
WOODPECKER_FORGEJO_CLIENT=<oauth2-client-id>
|
||||
WOODPECKER_FORGEJO_SECRET=<oauth2-client-secret>
|
||||
```
|
||||
|
||||
To create the OAuth2 app on Forgejo:
|
||||
|
||||
```bash
|
||||
# Create OAuth2 application (redirect URI = Woodpecker authorize endpoint)
|
||||
curl -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://localhost:3000/api/v1/user/applications/oauth2" \
|
||||
-d '{"name":"woodpecker-ci","redirect_uris":["http://localhost:8000/authorize"],"confidential_client":true}'
|
||||
```
|
||||
|
||||
The response contains `client_id` and `client_secret` for `WOODPECKER_FORGEJO_CLIENT` / `WOODPECKER_FORGEJO_SECRET`.
|
||||
|
||||
To activate the repo in Woodpecker:
|
||||
|
||||
```bash
|
||||
woodpecker-cli repo add <org>/<repo>
|
||||
# Or via API:
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ${WOODPECKER_TOKEN}" \
|
||||
"http://localhost:8000/api/repos" \
|
||||
-d '{"forge_remote_id":"<org>/<repo>"}'
|
||||
```
|
||||
|
||||
Woodpecker will now trigger pipelines on pushes to Forgejo and push commit status back. Disinto queries Woodpecker directly for CI status (with a forge API fallback), so pipeline results are visible even if Woodpecker's status push to Forgejo is delayed.
|
||||
|
||||
## 6. Prepare the Target Repo
|
||||
|
||||
### Required: CI pipeline
|
||||
|
||||
The repo needs at least one Woodpecker pipeline. Disinto monitors CI status to decide when a PR is ready for review and when it can merge.
|
||||
|
||||
### Required: `CLAUDE.md`
|
||||
|
||||
Create a `CLAUDE.md` in the repo root. This is the context document that dev-agent and review-agent read before working. It should cover:
|
||||
|
||||
- **What the project is** (one paragraph)
|
||||
- **Tech stack** (languages, frameworks, DB)
|
||||
- **How to build/run/test** (`npm install`, `npm test`, etc.)
|
||||
- **Coding conventions** (import style, naming, linting rules)
|
||||
- **Project structure** (key directories and what lives where)
|
||||
|
||||
The dev-agent reads this file via `claude -p` before implementing any issue. The better this file, the better the output.
|
||||
|
||||
### Required: Issue labels
|
||||
|
||||
`disinto init` creates these automatically. If setting up manually, create these labels on the forge repo:
|
||||
|
||||
| Label | Purpose |
|
||||
|-------|---------|
|
||||
| `backlog` | Issues ready to be picked up by dev-agent |
|
||||
| `in-progress` | Managed by dev-agent (auto-applied, auto-removed) |
|
||||
|
||||
Optional but recommended:
|
||||
|
||||
| Label | Purpose |
|
||||
|-------|---------|
|
||||
| `tech-debt` | Gardener can promote these to `backlog` |
|
||||
| `blocked` | Dev-agent marks issues with unmet dependencies |
|
||||
| `formula` | **Not yet functional.** Formula dispatch lives on the unmerged `feat/formula` branch. Dev-agent will skip any issue with this label until that branch is merged. Template files exist in `formulas/` for future use. |
|
||||
|
||||
### Required: Branch protection
|
||||
|
||||
On Forgejo, set up branch protection for your primary branch:
|
||||
|
||||
- **Require pull request reviews**: enabled
|
||||
- **Required approvals**: 1 (from the review bot account)
|
||||
- **Restrict push**: only allow merges via PR
|
||||
|
||||
This ensures dev-agent can't merge its own PRs — it must wait for review-agent (running as the bot account) to approve.
|
||||
|
||||
> **Common pitfall:** Approvals alone are not enough. You must also:
|
||||
> 1. Add `review-bot` as a **write** collaborator on the repo (Settings → Collaborators)
|
||||
> 2. Set both `approvals_whitelist_username` **and** `merge_whitelist_usernames` to include `review-bot` in the branch protection rule
|
||||
>
|
||||
> Without write access, the bot's approval is counted but the merge API returns HTTP 405.
|
||||
|
||||
### Required: Seed the `AGENTS.md` tree
|
||||
|
||||
The planner maintains an `AGENTS.md` tree — architecture docs with
|
||||
per-file `<!-- last-reviewed: SHA -->` watermarks. You must seed this before
|
||||
the first planner run, otherwise the planner sees no watermarks and treats the
|
||||
entire repo as "new", generating a noisy first-run diff.
|
||||
|
||||
1. **Create `AGENTS.md` in the repo root** with a one-page overview of the
|
||||
project: what it is, tech stack, directory layout, key conventions. Link
|
||||
to sub-directory AGENTS.md files.
|
||||
|
||||
2. **Create sub-directory `AGENTS.md` files** for each major directory
|
||||
(e.g. `frontend/AGENTS.md`, `backend/AGENTS.md`). Keep each under ~200
|
||||
lines — architecture and conventions, not implementation details.
|
||||
|
||||
3. **Set the watermark** on line 1 of every AGENTS.md file to the current HEAD:
|
||||
```bash
|
||||
SHA=$(git rev-parse --short HEAD)
|
||||
for f in $(find . -name "AGENTS.md" -not -path "./.git/*"); do
|
||||
sed -i "1s/^/<!-- last-reviewed: ${SHA} -->\n/" "$f"
|
||||
done
|
||||
```
|
||||
|
||||
4. **Symlink `CLAUDE.md`** so Claude Code picks up the same file:
|
||||
```bash
|
||||
ln -sf AGENTS.md CLAUDE.md
|
||||
```
|
||||
|
||||
5. Commit and push. The planner will now see 0 changes on its first run and
|
||||
only update files when real commits land.
|
||||
|
||||
See `formulas/run-planner.toml` (agents-update step) for the full AGENTS.md conventions.
|
||||
|
||||
## 7. Write Good Issues
|
||||
|
||||
Dev-agent works best with issues that have:
|
||||
|
||||
- **Clear title** describing the change (e.g., "Add email validation to customer form")
|
||||
- **Acceptance criteria** — what "done" looks like
|
||||
- **Dependencies** — reference blocking issues with `#NNN` in the body or a `## Dependencies` section:
|
||||
```
|
||||
## Dependencies
|
||||
- #4
|
||||
- #7
|
||||
```
|
||||
|
||||
Dev-agent checks that all referenced issues are closed (= merged) before starting work. If any are open, the issue is skipped and checked again next cycle.
|
||||
|
||||
## 8. Install Cron
|
||||
|
||||
```bash
|
||||
crontab -e
|
||||
```
|
||||
|
||||
### Single project
|
||||
|
||||
Add (adjust paths):
|
||||
|
||||
```cron
|
||||
FACTORY_ROOT=/home/you/disinto
|
||||
|
||||
# Supervisor — health checks, auto-healing (every 10 min)
|
||||
0,10,20,30,40,50 * * * * $FACTORY_ROOT/supervisor/supervisor-poll.sh
|
||||
|
||||
# Review agent — find unreviewed PRs (every 10 min, offset +3)
|
||||
3,13,23,33,43,53 * * * * $FACTORY_ROOT/review/review-poll.sh $FACTORY_ROOT/projects/myproject.toml
|
||||
|
||||
# Dev agent — find ready issues, implement (every 10 min, offset +6)
|
||||
6,16,26,36,46,56 * * * * $FACTORY_ROOT/dev/dev-poll.sh $FACTORY_ROOT/projects/myproject.toml
|
||||
|
||||
# Gardener — backlog grooming (daily)
|
||||
15 8 * * * $FACTORY_ROOT/gardener/gardener-poll.sh
|
||||
|
||||
# Planner — AGENTS.md maintenance + gap analysis (weekly)
|
||||
0 9 * * 1 $FACTORY_ROOT/planner/planner-poll.sh
|
||||
```
|
||||
|
||||
`review-poll.sh`, `dev-poll.sh`, and `gardener-poll.sh` all take a project TOML file as their first argument.
|
||||
|
||||
### Multiple projects
|
||||
|
||||
Stagger each project's polls so they don't overlap. With the example below, cross-project gaps are 2 minutes:
|
||||
|
||||
```cron
|
||||
FACTORY_ROOT=/home/you/disinto
|
||||
|
||||
# Supervisor (shared)
|
||||
0,10,20,30,40,50 * * * * $FACTORY_ROOT/supervisor/supervisor-poll.sh
|
||||
|
||||
# Project A — review +3, dev +6
|
||||
3,13,23,33,43,53 * * * * $FACTORY_ROOT/review/review-poll.sh $FACTORY_ROOT/projects/project-a.toml
|
||||
6,16,26,36,46,56 * * * * $FACTORY_ROOT/dev/dev-poll.sh $FACTORY_ROOT/projects/project-a.toml
|
||||
|
||||
# Project B — review +8, dev +1 (2-min gap from project A)
|
||||
8,18,28,38,48,58 * * * * $FACTORY_ROOT/review/review-poll.sh $FACTORY_ROOT/projects/project-b.toml
|
||||
1,11,21,31,41,51 * * * * $FACTORY_ROOT/dev/dev-poll.sh $FACTORY_ROOT/projects/project-b.toml
|
||||
|
||||
# Gardener — per-project backlog grooming (daily)
|
||||
15 8 * * * $FACTORY_ROOT/gardener/gardener-poll.sh $FACTORY_ROOT/projects/project-a.toml
|
||||
45 8 * * * $FACTORY_ROOT/gardener/gardener-poll.sh $FACTORY_ROOT/projects/project-b.toml
|
||||
|
||||
# Planner — AGENTS.md maintenance + gap analysis (weekly)
|
||||
0 9 * * 1 $FACTORY_ROOT/planner/planner-poll.sh
|
||||
```
|
||||
|
||||
The staggered offsets prevent agents from competing for resources. Each project gets its own lock file (`/tmp/dev-agent-{name}.lock`) derived from the `name` field in its TOML, so concurrent runs across projects are safe.
|
||||
|
||||
## 9. Verify
|
||||
|
||||
```bash
|
||||
# Should complete with "all clear" (no problems to fix)
|
||||
bash supervisor/supervisor-poll.sh
|
||||
|
||||
# Should list backlog issues (or "no backlog issues")
|
||||
bash dev/dev-poll.sh
|
||||
|
||||
# Should find no unreviewed PRs (or review one if exists)
|
||||
bash review/review-poll.sh
|
||||
```
|
||||
|
||||
Check logs after a few cycles:
|
||||
|
||||
```bash
|
||||
tail -30 supervisor/supervisor.log
|
||||
tail -30 dev/dev-agent.log
|
||||
tail -30 review/review.log
|
||||
```
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Once running, the system operates autonomously:
|
||||
|
||||
```
|
||||
You write issues (with backlog label)
|
||||
→ dev-poll finds ready issues
|
||||
→ dev-agent implements in a worktree, opens PR
|
||||
→ CI runs (Woodpecker)
|
||||
→ review-agent reviews, approves or requests changes
|
||||
→ dev-agent addresses feedback (if any)
|
||||
→ merge, close issue, clean up
|
||||
|
||||
Meanwhile:
|
||||
supervisor-poll monitors health, kills stale processes, manages resources
|
||||
gardener grooms backlog: closes duplicates, promotes tech-debt, escalates ambiguity
|
||||
planner rebuilds AGENTS.md from git history, gap-analyses against VISION.md
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
|---------|-------|
|
||||
| Dev-agent not picking up issues | `cat /tmp/dev-agent.lock` — is another instance running? Issues labeled `backlog`? Dependencies met? |
|
||||
| PR not getting reviewed | `tail review/review.log` — CI must pass first. Review bot token valid? |
|
||||
| CI stuck | `bash lib/ci-debug.sh` — check Woodpecker. Rate-limited? (exit 128 = wait 15 min) |
|
||||
| Claude not found | `which claude` — must be in PATH. Check `lib/env.sh` adds `~/.local/bin`. |
|
||||
| Merge fails | Branch protection misconfigured? Review bot needs write access to the repo. |
|
||||
| Memory issues | Supervisor auto-heals at <500 MB free. Check `supervisor/supervisor.log` for P0 alerts. |
|
||||
| Works on one box but not another | Diff configs first (`~/.claude/settings.json`, `.env`, crontab, branch protection). Write code never — config mismatches are the #1 cause of cross-box failures. |
|
||||
|
||||
### Multi-project common blockers
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| Dev-agent for project B never starts | Shared lock file path | Each TOML `name` field must be unique — lock is `/tmp/dev-agent-{name}.lock` |
|
||||
| Review-poll skips all PRs | CI gate with no CI configured | Set `woodpecker_repo_id = 0` in the TOML `[ci]` section to bypass the CI check |
|
||||
| Approved PRs never merge (HTTP 405) | `review-bot` not in merge/approvals whitelist | Add as write collaborator; set both `approvals_whitelist_username` and `merge_whitelist_usernames` in branch protection |
|
||||
| Dev-agent churns through issues without waiting for open PRs to land | No single-threaded enforcement | `WAITING_PRS` check in dev-poll holds new work — verify TOML `name` is consistent across invocations |
|
||||
| Label ping-pong (issue reopened then immediately re-closed) | `already_done` handler doesn't close issue | Review dev-agent log; `already_done` status should auto-close the issue |
|
||||
|
||||
## Security: Docker Socket Sharing in CI
|
||||
|
||||
The `woodpecker-agent` service mounts `/var/run/docker.sock` to execute `type: docker` CI pipelines. This grants root-equivalent access to the Docker host — any CI pipeline step can run privileged containers, mount arbitrary host paths, or access other containers' data.
|
||||
|
||||
**Mitigations:**
|
||||
|
||||
- **Run disinto in an LXD/VM container, not on bare metal.** When the Docker daemon runs inside an LXD container, LXD's user namespace mapping and resource limits contain the blast radius. A compromised CI step cannot reach the real host.
|
||||
- **`WOODPECKER_MAX_WORKFLOWS: 1`** limits concurrent CI resource usage, preventing a runaway pipeline from exhausting host resources.
|
||||
- **`WOODPECKER_AGENT_SECRET`** authenticates the agent↔server gRPC connection. `disinto init` auto-generates this secret and stores it in `.env` (or `.env.enc` when SOPS is available).
|
||||
- Consider setting `WOODPECKER_BACKEND_DOCKER_VOLUMES` on the agent to restrict which host volumes CI pipelines can mount.
|
||||
|
||||
**Threat model:** PRs are created by the dev-agent (Claude) and auto-reviewed by the review-bot. A crafted backlog issue could theoretically produce a PR whose CI step exploits the Docker socket. The LXD containment boundary is the primary defense — treat the LXD container as the trust boundary, not the Docker daemon inside it.
|
||||
|
||||
## Action Runner — disinto (harb-staging)
|
||||
|
||||
Added 2026-03-19. Polls disinto repo for `action`-labeled issues.
|
||||
|
||||
```
|
||||
*/5 * * * * cd /home/debian/dark-factory && bash action/action-poll.sh projects/disinto.toml >> /tmp/action-disinto-cron.log 2>&1
|
||||
```
|
||||
|
||||
Runs locally on harb-staging — same box where Caddy/site live. For formulas that need local resources (publish-site, etc).
|
||||
|
||||
### Fix applied: action-agent.sh needs +x
|
||||
The script wasn't executable after git clone. Run:
|
||||
```bash
|
||||
chmod +x action/action-agent.sh action/action-poll.sh
|
||||
```
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This repo is **disinto** — an autonomous code factory.
|
||||
|
||||
Read `AGENTS.md` for architecture, coding conventions, and per-file documentation.
|
||||
For setup and operations, load the `disinto-factory` skill (`disinto-factory/SKILL.md`).
|
||||
19
README.md
19
README.md
|
|
@ -37,6 +37,9 @@ cron (daily) ──→ gardener-poll.sh ← backlog grooming (duplicates, stale
|
|||
cron (weekly) ──→ planner-poll.sh ← gap-analyse VISION.md, create backlog issues
|
||||
└── claude -p: update AGENTS.md → create issues
|
||||
|
||||
cron (*/30) ──→ vault-poll.sh ← safety gate for dangerous/irreversible actions
|
||||
└── claude -p: classify → auto-approve/reject or escalate
|
||||
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
|
@ -93,6 +96,7 @@ crontab -e
|
|||
# 3,13,23,33,43,53 * * * * /path/to/disinto/review/review-poll.sh
|
||||
# 6,16,26,36,46,56 * * * * /path/to/disinto/dev/dev-poll.sh
|
||||
# 15 8 * * * /path/to/disinto/gardener/gardener-poll.sh
|
||||
# 0,30 * * * * /path/to/disinto/vault/vault-poll.sh
|
||||
# 0 9 * * 1 /path/to/disinto/planner/planner-poll.sh
|
||||
|
||||
# 4. Verify
|
||||
|
|
@ -119,13 +123,16 @@ disinto/
|
|||
│ └── best-practices.md # Gardener knowledge base
|
||||
├── planner/
|
||||
│ ├── planner-poll.sh # Cron entry: weekly vision gap analysis
|
||||
│ └── (formula-driven) # run-planner.toml executed by dispatcher
|
||||
│ └── (formula-driven) # run-planner.toml executed by action-agent
|
||||
├── vault/
|
||||
│ └── vault-env.sh # Shared env setup (vault redesign in progress, see #73-#77)
|
||||
├── docs/
|
||||
│ └── VAULT.md # Vault PR workflow and branch protection documentation
|
||||
│ ├── vault-poll.sh # Cron entry: process pending dangerous actions
|
||||
│ ├── vault-agent.sh # Classifies and routes actions (claude -p)
|
||||
│ ├── vault-fire.sh # Executes an approved action
|
||||
│ ├── vault-reject.sh # Marks an action as rejected
|
||||
│ └── PROMPT.md # System prompt for vault agent
|
||||
└── supervisor/
|
||||
├── supervisor-poll.sh # Supervisor: health checks + claude -p
|
||||
├── PROMPT.md # Supervisor's system prompt
|
||||
├── update-prompt.sh # Self-learning: append to best-practices
|
||||
└── best-practices/ # Progressive disclosure knowledge base
|
||||
├── memory.md
|
||||
|
|
@ -146,9 +153,7 @@ disinto/
|
|||
| **Review** | Every 10 min | Finds PRs without review, runs Claude-powered code review, approves or requests changes. |
|
||||
| **Gardener** | Daily | Grooms the issue backlog: detects duplicates, promotes `tech-debt` to `backlog`, closes stale issues, escalates ambiguous items. |
|
||||
| **Planner** | Weekly | Updates AGENTS.md documentation to reflect recent code changes, then gap-analyses VISION.md vs current state and creates up to 5 backlog issues for the highest-leverage gaps. |
|
||||
|
||||
> **Vault:** Being redesigned as a PR-based approval workflow (issues #73-#77).
|
||||
> See [docs/VAULT.md](docs/VAULT.md) for the vault PR workflow and branch protection details.
|
||||
| **Vault** | Every 30 min | Safety gate for dangerous or irreversible actions. Classifies pending actions via Claude: auto-approve, auto-reject, or escalate to a human via vault/forge. |
|
||||
|
||||
## Design Principles
|
||||
|
||||
|
|
|
|||
34
action/AGENTS.md
Normal file
34
action/AGENTS.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<!-- last-reviewed: 9b91c6a5bdd83513ca262dd468ea14db675971c1 -->
|
||||
# Action Agent
|
||||
|
||||
**Role**: Execute operational tasks described by action formulas — run scripts,
|
||||
call APIs, send messages, collect human approval. Shares the same phase handler
|
||||
as the dev-agent: if an action produces code changes, the orchestrator creates a
|
||||
PR and drives the CI/review loop; otherwise Claude closes the issue directly.
|
||||
|
||||
**Trigger**: `action-poll.sh` runs every 10 min via cron. Sources `lib/guard.sh`
|
||||
and calls `check_active action` first — skips if `$FACTORY_ROOT/state/.action-active`
|
||||
is absent. Then scans for open issues labeled `action` that have no active tmux
|
||||
session, and spawns `action-agent.sh <issue-number>`.
|
||||
|
||||
**Key files**:
|
||||
- `action/action-poll.sh` — Cron scheduler: finds open action issues with no active tmux session, spawns action-agent.sh
|
||||
- `action/action-agent.sh` — Orchestrator: fetches issue body + prior comments, **checks all dependencies via `lib/parse-deps.sh` before spawning** (skips silently if any dep is still open), creates tmux session (`action-{project}-{issue_num}`) with interactive `claude`, injects formula prompt with phase protocol, enters `monitor_phase_loop` (shared via `dev/phase-handler.sh`) for CI/review lifecycle or direct completion
|
||||
|
||||
**Session lifecycle**:
|
||||
1. `action-poll.sh` finds open `action` issues with no active tmux session.
|
||||
2. Spawns `action-agent.sh <issue_num>`.
|
||||
3. Agent creates tmux session `action-{project}-{issue_num}`, injects prompt (formula + prior comments + phase protocol).
|
||||
4. Agent enters `monitor_phase_loop` (shared with dev-agent via `dev/phase-handler.sh`).
|
||||
5. **Path A (git output):** Claude pushes branch → `PHASE:awaiting_ci` → handler creates PR, polls CI → injects failures → Claude fixes → push → re-poll → CI passes → `PHASE:awaiting_review` → handler polls reviews → injects REQUEST_CHANGES → Claude fixes → approved → merge → cleanup.
|
||||
6. **Path B (no git output):** Claude posts results as comment, closes issue → `PHASE:done` → handler cleans up (kill session, docker compose down, remove temp files).
|
||||
7. For human input: Claude writes `PHASE:escalate`; human responds via vault/forge.
|
||||
|
||||
**Crash recovery**: on `PHASE:crashed` or non-zero exit, the worktree is **preserved** (not destroyed) for debugging. Location logged. Supervisor housekeeping removes stale crashed worktrees older than 24h.
|
||||
|
||||
**Environment variables consumed**:
|
||||
- `FORGE_TOKEN`, `FORGE_ACTION_TOKEN` (falls back to FORGE_TOKEN), `FORGE_REPO`, `FORGE_API`, `FORGE_URL`, `PROJECT_NAME`, `FORGE_WEB`
|
||||
- `ACTION_IDLE_TIMEOUT` — Max seconds before killing idle session (default 14400 = 4h)
|
||||
- `ACTION_MAX_LIFETIME` — Max total session wall-clock seconds (default 28800 = 8h); caps session independently of idle timeout
|
||||
|
||||
**FORGE_REMOTE**: `action-agent.sh` auto-detects the git remote for `FORGE_URL` (same logic as dev-agent). Exported as `FORGE_REMOTE`, used for worktree creation and push instructions injected into the Claude prompt.
|
||||
363
action/action-agent.sh
Executable file
363
action/action-agent.sh
Executable file
|
|
@ -0,0 +1,363 @@
|
|||
#!/usr/bin/env bash
|
||||
# action-agent.sh — Autonomous action agent: tmux + Claude + action formula
|
||||
#
|
||||
# Usage: ./action-agent.sh <issue-number> [project.toml]
|
||||
#
|
||||
# Lifecycle:
|
||||
# 1. Fetch issue body (action formula) + existing comments
|
||||
# 2. Create isolated git worktree: /tmp/action-{issue}-{timestamp}
|
||||
# 3. Create tmux session: action-{project}-{issue_num} with interactive claude in worktree
|
||||
# 4. Inject initial prompt: formula + comments + phase protocol instructions
|
||||
# 5. Monitor phase file via monitor_phase_loop (shared with dev-agent)
|
||||
# Path A (git output): Claude pushes → handler creates PR → CI poll → review
|
||||
# injection → merge → cleanup (same loop as dev-agent via phase-handler.sh)
|
||||
# Path B (no git output): Claude posts results → PHASE:done → cleanup
|
||||
# 6. For human input: Claude writes PHASE:escalate; human responds via vault/forge
|
||||
# 7. Cleanup on terminal phase: kill children, destroy worktree, remove temp files
|
||||
#
|
||||
# Key principle: The runtime creates and destroys. The formula preserves.
|
||||
# The formula must push results before signaling done — the worktree is nuked after.
|
||||
#
|
||||
# Session: action-{project}-{issue_num} (tmux)
|
||||
# Log: action/action-poll-{project}.log
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ISSUE="${1:?Usage: action-agent.sh <issue-number> [project.toml]}"
|
||||
export PROJECT_TOML="${2:-${PROJECT_TOML:-}}"
|
||||
|
||||
source "$(dirname "$0")/../lib/env.sh"
|
||||
# Use action-bot's own Forgejo identity (#747)
|
||||
FORGE_TOKEN="${FORGE_ACTION_TOKEN:-${FORGE_TOKEN}}"
|
||||
source "$(dirname "$0")/../lib/ci-helpers.sh"
|
||||
source "$(dirname "$0")/../lib/agent-session.sh"
|
||||
source "$(dirname "$0")/../lib/formula-session.sh"
|
||||
# shellcheck source=../dev/phase-handler.sh
|
||||
source "$(dirname "$0")/../dev/phase-handler.sh"
|
||||
SESSION_NAME="action-${PROJECT_NAME}-${ISSUE}"
|
||||
LOCKFILE="/tmp/action-agent-${ISSUE}.lock"
|
||||
LOGFILE="${FACTORY_ROOT}/action/action-poll-${PROJECT_NAME:-default}.log"
|
||||
IDLE_TIMEOUT="${ACTION_IDLE_TIMEOUT:-14400}" # 4h default
|
||||
MAX_LIFETIME="${ACTION_MAX_LIFETIME:-28800}" # 8h default wall-clock cap
|
||||
SESSION_START_EPOCH=$(date +%s)
|
||||
|
||||
# --- Phase handler globals (agent-specific; defaults in phase-handler.sh) ---
|
||||
# shellcheck disable=SC2034 # used by phase-handler.sh
|
||||
API="${FORGE_API}"
|
||||
BRANCH="action/issue-${ISSUE}"
|
||||
# shellcheck disable=SC2034 # used by phase-handler.sh
|
||||
WORKTREE="/tmp/action-${ISSUE}-$(date +%s)"
|
||||
PHASE_FILE="/tmp/action-session-${PROJECT_NAME:-default}-${ISSUE}.phase"
|
||||
IMPL_SUMMARY_FILE="/tmp/action-impl-summary-${PROJECT_NAME:-default}-${ISSUE}.txt"
|
||||
PREFLIGHT_RESULT="/tmp/action-preflight-${ISSUE}.json"
|
||||
SCRATCH_FILE="/tmp/action-${ISSUE}-scratch.md"
|
||||
|
||||
log() {
|
||||
printf '[%s] action#%s %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$ISSUE" "$*" >> "$LOGFILE"
|
||||
}
|
||||
|
||||
status() {
|
||||
log "$*"
|
||||
}
|
||||
|
||||
# --- Action-specific helpers for phase-handler.sh ---
|
||||
cleanup_worktree() {
|
||||
cd "${PROJECT_REPO_ROOT}" 2>/dev/null || true
|
||||
git worktree remove "$WORKTREE" --force 2>/dev/null || true
|
||||
rm -rf "$WORKTREE"
|
||||
# Clear Claude Code session history for this worktree to prevent hallucinated "already done"
|
||||
local claude_project_dir
|
||||
claude_project_dir="$HOME/.claude/projects/$(echo "$WORKTREE" | sed 's|/|-|g; s|^-||')"
|
||||
rm -rf "$claude_project_dir" 2>/dev/null || true
|
||||
log "destroyed worktree: ${WORKTREE}"
|
||||
}
|
||||
cleanup_labels() { :; } # action agent doesn't use in-progress labels
|
||||
|
||||
# --- Concurrency lock (per issue) ---
|
||||
if [ -f "$LOCKFILE" ]; then
|
||||
LOCK_PID=$(cat "$LOCKFILE" 2>/dev/null || echo "")
|
||||
if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then
|
||||
log "SKIP: action-agent already running for #${ISSUE} (PID ${LOCK_PID})"
|
||||
exit 0
|
||||
fi
|
||||
rm -f "$LOCKFILE"
|
||||
fi
|
||||
echo $$ > "$LOCKFILE"
|
||||
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
# Kill lifetime watchdog if running
|
||||
if [ -n "${LIFETIME_WATCHDOG_PID:-}" ] && kill -0 "$LIFETIME_WATCHDOG_PID" 2>/dev/null; then
|
||||
kill "$LIFETIME_WATCHDOG_PID" 2>/dev/null || true
|
||||
wait "$LIFETIME_WATCHDOG_PID" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$LOCKFILE"
|
||||
agent_kill_session "$SESSION_NAME"
|
||||
# Kill any remaining child processes spawned during the run
|
||||
local children
|
||||
children=$(jobs -p 2>/dev/null) || true
|
||||
if [ -n "$children" ]; then
|
||||
# shellcheck disable=SC2086 # intentional word splitting
|
||||
kill $children 2>/dev/null || true
|
||||
# shellcheck disable=SC2086
|
||||
wait $children 2>/dev/null || true
|
||||
fi
|
||||
# Best-effort docker cleanup for containers started during this action
|
||||
(cd "${WORKTREE}" 2>/dev/null && docker compose down 2>/dev/null) || true
|
||||
# Preserve worktree on crash for debugging; clean up on success
|
||||
local final_phase=""
|
||||
[ -f "$PHASE_FILE" ] && final_phase=$(head -1 "$PHASE_FILE" 2>/dev/null || true)
|
||||
if [ "${final_phase:-}" = "PHASE:crashed" ] || [ "${_MONITOR_LOOP_EXIT:-}" = "crashed" ] || [ "$exit_code" -ne 0 ]; then
|
||||
log "PRESERVED crashed worktree for debugging: $WORKTREE"
|
||||
else
|
||||
cleanup_worktree
|
||||
fi
|
||||
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" "$IMPL_SUMMARY_FILE" "$PREFLIGHT_RESULT"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# --- Memory guard ---
|
||||
AVAIL_MB=$(awk '/MemAvailable/ {printf "%d", $2/1024}' /proc/meminfo)
|
||||
if [ "$AVAIL_MB" -lt 2000 ]; then
|
||||
log "SKIP: only ${AVAIL_MB}MB available (need 2000MB)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Fetch issue ---
|
||||
log "fetching issue #${ISSUE}"
|
||||
ISSUE_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues/${ISSUE}") || true
|
||||
|
||||
if [ -z "$ISSUE_JSON" ] || ! printf '%s' "$ISSUE_JSON" | jq -e '.id' >/dev/null 2>&1; then
|
||||
log "ERROR: failed to fetch issue #${ISSUE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ISSUE_TITLE=$(printf '%s' "$ISSUE_JSON" | jq -r '.title')
|
||||
ISSUE_BODY=$(printf '%s' "$ISSUE_JSON" | jq -r '.body // ""')
|
||||
ISSUE_STATE=$(printf '%s' "$ISSUE_JSON" | jq -r '.state')
|
||||
|
||||
if [ "$ISSUE_STATE" != "open" ]; then
|
||||
log "SKIP: issue #${ISSUE} is ${ISSUE_STATE}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "Issue: ${ISSUE_TITLE}"
|
||||
|
||||
# --- Dependency check (skip before spawning Claude) ---
|
||||
DEPS=$(printf '%s' "$ISSUE_BODY" | bash "${FACTORY_ROOT}/lib/parse-deps.sh")
|
||||
if [ -n "$DEPS" ]; then
|
||||
ALL_MET=true
|
||||
while IFS= read -r dep; do
|
||||
[ -z "$dep" ] && continue
|
||||
DEP_STATE=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues/${dep}" | jq -r '.state // "open"') || DEP_STATE="open"
|
||||
if [ "$DEP_STATE" != "closed" ]; then
|
||||
log "SKIP: dependency #${dep} still open — not spawning session"
|
||||
ALL_MET=false
|
||||
break
|
||||
fi
|
||||
done <<< "$DEPS"
|
||||
if [ "$ALL_MET" = false ]; then
|
||||
rm -f "$LOCKFILE"
|
||||
exit 0
|
||||
fi
|
||||
log "all dependencies met"
|
||||
fi
|
||||
|
||||
# --- Extract model from YAML front matter (if present) ---
|
||||
YAML_MODEL=$(printf '%s' "$ISSUE_BODY" | \
|
||||
sed -n '/^---$/,/^---$/p' | grep '^model:' | awk '{print $2}' | tr -d '"' || true)
|
||||
if [ -n "$YAML_MODEL" ]; then
|
||||
export CLAUDE_MODEL="$YAML_MODEL"
|
||||
log "model from front matter: ${YAML_MODEL}"
|
||||
fi
|
||||
|
||||
# --- Resolve bot username(s) for comment filtering ---
|
||||
_bot_login=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API%%/repos*}/user" | jq -r '.login // empty' 2>/dev/null || true)
|
||||
|
||||
# Build list: token owner + any extra names from FORGE_BOT_USERNAMES (comma-separated)
|
||||
_bot_logins="${_bot_login}"
|
||||
if [ -n "${FORGE_BOT_USERNAMES:-}" ]; then
|
||||
_bot_logins="${_bot_logins:+${_bot_logins},}${FORGE_BOT_USERNAMES}"
|
||||
fi
|
||||
|
||||
# --- Fetch existing comments (resume context, excluding bot comments) ---
|
||||
COMMENTS_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues/${ISSUE}/comments?limit=50") || true
|
||||
|
||||
PRIOR_COMMENTS=""
|
||||
if [ -n "$COMMENTS_JSON" ] && [ "$COMMENTS_JSON" != "null" ] && [ "$COMMENTS_JSON" != "[]" ]; then
|
||||
PRIOR_COMMENTS=$(printf '%s' "$COMMENTS_JSON" | \
|
||||
jq -r --arg bots "$_bot_logins" \
|
||||
'($bots | split(",") | map(select(. != ""))) as $bl |
|
||||
.[] | select(.user.login as $u | $bl | index($u) | not) |
|
||||
"[\(.user.login) at \(.created_at[:19])]\n\(.body)\n---"' 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# --- Create isolated worktree ---
|
||||
log "creating worktree: ${WORKTREE}"
|
||||
cd "${PROJECT_REPO_ROOT}"
|
||||
|
||||
# Determine which git remote corresponds to FORGE_URL
|
||||
_forge_host=$(echo "$FORGE_URL" | sed 's|https\?://||; s|/.*||')
|
||||
FORGE_REMOTE=$(git remote -v | awk -v host="$_forge_host" '$2 ~ host && /\(push\)/ {print $1; exit}')
|
||||
FORGE_REMOTE="${FORGE_REMOTE:-origin}"
|
||||
export FORGE_REMOTE
|
||||
|
||||
git fetch "${FORGE_REMOTE}" "${PRIMARY_BRANCH}" 2>/dev/null || true
|
||||
if ! git worktree add "$WORKTREE" "${FORGE_REMOTE}/${PRIMARY_BRANCH}" 2>&1; then
|
||||
log "ERROR: worktree creation failed"
|
||||
exit 1
|
||||
fi
|
||||
log "worktree ready: ${WORKTREE}"
|
||||
|
||||
# --- Read scratch file (compaction survival) ---
|
||||
SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
|
||||
SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
|
||||
|
||||
# --- Build initial prompt ---
|
||||
PRIOR_SECTION=""
|
||||
if [ -n "$PRIOR_COMMENTS" ]; then
|
||||
PRIOR_SECTION="## Prior comments (resume context)
|
||||
|
||||
${PRIOR_COMMENTS}
|
||||
|
||||
"
|
||||
fi
|
||||
|
||||
# Build phase protocol from shared function (Path B covered in Instructions section above)
|
||||
PHASE_PROTOCOL_INSTRUCTIONS="$(build_phase_protocol_prompt "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$BRANCH")"
|
||||
|
||||
# Write phase protocol to context file for compaction survival
|
||||
write_compact_context "$PHASE_FILE" "$PHASE_PROTOCOL_INSTRUCTIONS"
|
||||
|
||||
INITIAL_PROMPT="You are an action agent. Your job is to execute the action formula
|
||||
in the issue below.
|
||||
|
||||
## Issue #${ISSUE}: ${ISSUE_TITLE}
|
||||
|
||||
${ISSUE_BODY}
|
||||
${SCRATCH_CONTEXT}
|
||||
${PRIOR_SECTION}## Instructions
|
||||
|
||||
1. Read the action formula steps in the issue body carefully.
|
||||
|
||||
2. Execute each step in order using your Bash tool and any other tools available.
|
||||
|
||||
3. Post progress as comments on issue #${ISSUE} after significant steps:
|
||||
curl -sf -X POST \\
|
||||
-H \"Authorization: token \${FORGE_TOKEN}\" \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
\"${FORGE_API}/issues/${ISSUE}/comments\" \\
|
||||
-d \"{\\\"body\\\": \\\"your comment here\\\"}\"
|
||||
|
||||
4. If a step requires human input or approval, write PHASE:escalate with a reason.
|
||||
A human will review and respond via the forge.
|
||||
|
||||
### Path A: If this action produces code changes (e.g. config updates, baselines):
|
||||
- You are already in an isolated worktree at: ${WORKTREE}
|
||||
- Create and switch to branch: git checkout -b ${BRANCH}
|
||||
- Make your changes, commit, and push: git push ${FORGE_REMOTE} ${BRANCH}
|
||||
- **IMPORTANT:** The worktree is destroyed after completion. Push all
|
||||
results before signaling done — unpushed work will be lost.
|
||||
- Follow the phase protocol below — the orchestrator handles PR creation,
|
||||
CI monitoring, and review injection.
|
||||
|
||||
### Path B: If this action produces no code changes (investigation, report):
|
||||
- Post results as a comment on issue #${ISSUE}.
|
||||
- **IMPORTANT:** The worktree is destroyed after completion. Copy any
|
||||
files you need to persistent paths before signaling done.
|
||||
- Close the issue:
|
||||
curl -sf -X PATCH \\
|
||||
-H \"Authorization: token \${FORGE_TOKEN}\" \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
\"${FORGE_API}/issues/${ISSUE}\" \\
|
||||
-d '{\"state\": \"closed\"}'
|
||||
- Signal completion: echo \"PHASE:done\" > \"${PHASE_FILE}\"
|
||||
|
||||
5. Environment variables available in your bash sessions:
|
||||
FORGE_TOKEN, FORGE_API, FORGE_REPO, FORGE_WEB, PROJECT_NAME
|
||||
(all sourced from ${FACTORY_ROOT}/.env)
|
||||
|
||||
### CRITICAL: Never embed secrets in issue bodies, comments, or PR descriptions
|
||||
- NEVER put API keys, tokens, passwords, or private keys in issue text or comments.
|
||||
- Always reference secrets via env var names (e.g. \\\$BASE_RPC_URL, \\\${FORGE_TOKEN}).
|
||||
- If a formula step needs a secret, read it from .env or the environment at runtime.
|
||||
- Before posting any comment, verify it contains no credentials, hex keys > 32 chars,
|
||||
or URLs with embedded API keys.
|
||||
|
||||
If the prior comments above show work already completed, resume from where it
|
||||
left off.
|
||||
|
||||
${SCRATCH_INSTRUCTION}
|
||||
|
||||
${PHASE_PROTOCOL_INSTRUCTIONS}"
|
||||
|
||||
# --- Create tmux session ---
|
||||
log "creating tmux session: ${SESSION_NAME}"
|
||||
if ! create_agent_session "${SESSION_NAME}" "${WORKTREE}" "${PHASE_FILE}"; then
|
||||
log "ERROR: failed to create tmux session"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Inject initial prompt ---
|
||||
inject_formula "${SESSION_NAME}" "${INITIAL_PROMPT}"
|
||||
log "initial prompt injected into session"
|
||||
|
||||
# --- Wall-clock lifetime watchdog (background) ---
|
||||
# Caps total session time independently of idle timeout. When the cap is
|
||||
# hit the watchdog kills the tmux session, posts a summary comment on the
|
||||
# issue, and writes PHASE:failed so monitor_phase_loop exits.
|
||||
_lifetime_watchdog() {
|
||||
local remaining=$(( MAX_LIFETIME - ($(date +%s) - SESSION_START_EPOCH) ))
|
||||
[ "$remaining" -le 0 ] && remaining=1
|
||||
sleep "$remaining"
|
||||
local hours=$(( MAX_LIFETIME / 3600 ))
|
||||
log "MAX_LIFETIME (${hours}h) reached — killing session"
|
||||
agent_kill_session "$SESSION_NAME"
|
||||
# Post summary comment on issue
|
||||
local body="Action session killed: wall-clock lifetime cap (${hours}h) reached."
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${FORGE_API}/issues/${ISSUE}/comments" \
|
||||
-d "{\"body\": \"${body}\"}" >/dev/null 2>&1 || true
|
||||
printf 'PHASE:failed\nReason: max_lifetime (%sh) reached\n' "$hours" > "$PHASE_FILE"
|
||||
# Touch phase-changed marker so monitor_phase_loop picks up immediately
|
||||
touch "/tmp/phase-changed-${SESSION_NAME}.marker"
|
||||
}
|
||||
_lifetime_watchdog &
|
||||
LIFETIME_WATCHDOG_PID=$!
|
||||
|
||||
# --- Monitor phase loop (shared with dev-agent) ---
|
||||
status "monitoring phase: ${PHASE_FILE} (action agent)"
|
||||
monitor_phase_loop "$PHASE_FILE" "$IDLE_TIMEOUT" _on_phase_change "$SESSION_NAME"
|
||||
|
||||
# Handle exit reason from monitor_phase_loop
|
||||
case "${_MONITOR_LOOP_EXIT:-}" in
|
||||
idle_timeout)
|
||||
# Post diagnostic comment + label blocked
|
||||
post_blocked_diagnostic "idle_timeout"
|
||||
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" "$IMPL_SUMMARY_FILE" "$SCRATCH_FILE"
|
||||
;;
|
||||
idle_prompt)
|
||||
# Notification + blocked label already handled by _on_phase_change(PHASE:failed) callback
|
||||
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" "$IMPL_SUMMARY_FILE" "$SCRATCH_FILE"
|
||||
;;
|
||||
PHASE:failed)
|
||||
# Check if this was a max_lifetime kill (phase file contains the reason)
|
||||
if grep -q 'max_lifetime' "$PHASE_FILE" 2>/dev/null; then
|
||||
post_blocked_diagnostic "max_lifetime"
|
||||
fi
|
||||
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" "$IMPL_SUMMARY_FILE" "$SCRATCH_FILE"
|
||||
;;
|
||||
done)
|
||||
# Belt-and-suspenders: callback handles primary cleanup,
|
||||
# but ensure sentinel files are removed if callback was interrupted
|
||||
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" "$IMPL_SUMMARY_FILE" "$SCRATCH_FILE"
|
||||
;;
|
||||
esac
|
||||
|
||||
log "action-agent finished for issue #${ISSUE}"
|
||||
75
action/action-poll.sh
Executable file
75
action/action-poll.sh
Executable file
|
|
@ -0,0 +1,75 @@
|
|||
#!/usr/bin/env bash
|
||||
# action-poll.sh — Cron scheduler: find open 'action' issues, spawn action-agent
|
||||
#
|
||||
# An issue is ready for action if:
|
||||
# - It is open and labeled 'action'
|
||||
# - No tmux session named action-{project}-{issue_num} is already active
|
||||
#
|
||||
# Usage:
|
||||
# cron every 10min
|
||||
# action-poll.sh [projects/foo.toml] # optional project config
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
export PROJECT_TOML="${1:-}"
|
||||
source "$(dirname "$0")/../lib/env.sh"
|
||||
# Use action-bot's own Forgejo identity (#747)
|
||||
FORGE_TOKEN="${FORGE_ACTION_TOKEN:-${FORGE_TOKEN}}"
|
||||
# shellcheck source=../lib/guard.sh
|
||||
source "$(dirname "$0")/../lib/guard.sh"
|
||||
check_active action
|
||||
|
||||
LOGFILE="${FACTORY_ROOT}/action/action-poll-${PROJECT_NAME:-default}.log"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
log() {
|
||||
printf '[%s] poll: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
|
||||
}
|
||||
|
||||
# --- Memory guard ---
|
||||
memory_guard 2000
|
||||
|
||||
# --- Find open 'action' issues ---
|
||||
log "scanning for open action issues"
|
||||
ACTION_ISSUES=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues?state=open&labels=action&limit=50&type=issues") || true
|
||||
|
||||
if [ -z "$ACTION_ISSUES" ] || [ "$ACTION_ISSUES" = "null" ]; then
|
||||
log "no action issues found"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
COUNT=$(printf '%s' "$ACTION_ISSUES" | jq 'length')
|
||||
if [ "$COUNT" -eq 0 ]; then
|
||||
log "no action issues found"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "found ${COUNT} open action issue(s)"
|
||||
|
||||
# Spawn action-agent for each issue that has no active tmux session.
|
||||
# Only one agent is spawned per poll to avoid memory pressure; the next
|
||||
# poll picks up remaining issues.
|
||||
for i in $(seq 0 $((COUNT - 1))); do
|
||||
ISSUE_NUM=$(printf '%s' "$ACTION_ISSUES" | jq -r ".[$i].number")
|
||||
SESSION="action-${PROJECT_NAME}-${ISSUE_NUM}"
|
||||
|
||||
if tmux has-session -t "$SESSION" 2>/dev/null; then
|
||||
log "issue #${ISSUE_NUM}: session ${SESSION} already active, skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
LOCKFILE="/tmp/action-agent-${ISSUE_NUM}.lock"
|
||||
if [ -f "$LOCKFILE" ]; then
|
||||
LOCK_PID=$(cat "$LOCKFILE" 2>/dev/null || echo "")
|
||||
if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then
|
||||
log "issue #${ISSUE_NUM}: agent starting (PID ${LOCK_PID}), skipping"
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
log "spawning action-agent for issue #${ISSUE_NUM}"
|
||||
nohup "${SCRIPT_DIR}/action-agent.sh" "$ISSUE_NUM" "$PROJECT_TOML" >> "$LOGFILE" 2>&1 &
|
||||
log "started action-agent PID $! for issue #${ISSUE_NUM}"
|
||||
break
|
||||
done
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
<!-- last-reviewed: ac2beac361503c8712ecfc72be0401b5968cce4e -->
|
||||
# Architect — Agent Instructions
|
||||
|
||||
## What this agent is
|
||||
|
||||
The architect is a strategic decomposition agent that breaks down vision issues
|
||||
into development sprints. It proposes sprints via PRs on the ops repo and
|
||||
converses with humans through PR comments.
|
||||
|
||||
## Role
|
||||
|
||||
- **Input**: Vision issues from VISION.md, prerequisite tree from ops repo
|
||||
- **Output**: Sprint proposals as PRs on the ops repo, sub-issue files
|
||||
- **Mechanism**: Formula-driven execution via `formulas/run-architect.toml`
|
||||
- **Identity**: `architect-bot` on Forgejo
|
||||
|
||||
## Responsibilities
|
||||
|
||||
1. **Strategic decomposition**: Break down large vision items into coherent
|
||||
sprints that can be executed by the dev agent
|
||||
2. **Design fork identification**: When multiple implementation approaches exist,
|
||||
identify the forks and file sub-issues for each path
|
||||
3. **Sprint PR creation**: Propose sprints as PRs on the ops repo with clear
|
||||
acceptance criteria and dependencies
|
||||
4. **Human conversation**: Respond to PR comments, refine sprint proposals based
|
||||
on human feedback
|
||||
5. **Sub-issue filing**: After design forks are resolved, file concrete sub-issues
|
||||
for implementation
|
||||
|
||||
## Formula
|
||||
|
||||
The architect is driven by `formulas/run-architect.toml`. This formula defines
|
||||
the steps for:
|
||||
- Research: analyzing vision items and prerequisite tree
|
||||
- Design: identifying implementation approaches and forks
|
||||
- Sprint proposal: creating structured sprint PRs
|
||||
- Sub-issue filing: creating concrete implementation issues
|
||||
|
||||
## Execution
|
||||
|
||||
Run via `architect/architect-run.sh`, which:
|
||||
- Acquires a cron lock and checks available memory
|
||||
- Sources shared libraries (env.sh, formula-session.sh)
|
||||
- Uses FORGE_ARCHITECT_TOKEN for authentication
|
||||
- Loads the formula and builds context from VISION.md, AGENTS.md, and ops repo
|
||||
- Executes the formula via `agent_run`
|
||||
|
||||
## Cron
|
||||
|
||||
Suggested cron entry (every 6 hours):
|
||||
```cron
|
||||
0 */6 * * * cd /path/to/disinto && bash architect/architect-run.sh
|
||||
```
|
||||
|
||||
## State
|
||||
|
||||
Architect state is tracked in `state/.architect-active` (disabled by default —
|
||||
empty file not created, just document it).
|
||||
|
||||
## Related issues
|
||||
|
||||
- #96: Architect agent parent issue
|
||||
- #100: Architect formula — research + design fork identification
|
||||
- #101: Architect formula — sprint PR creation with questions
|
||||
- #102: Architect formula — answer parsing + sub-issue filing
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# architect-run.sh — Cron wrapper: architect execution via SDK + formula
|
||||
#
|
||||
# Synchronous bash loop using claude -p (one-shot invocation).
|
||||
# No tmux sessions, no phase files — the bash script IS the state machine.
|
||||
#
|
||||
# Flow:
|
||||
# 1. Guards: cron lock, memory check
|
||||
# 2. Load formula (formulas/run-architect.toml)
|
||||
# 3. Context: VISION.md, AGENTS.md, ops:prerequisites.md, structural graph
|
||||
# 4. agent_run(worktree, prompt) → Claude decomposes vision into sprints
|
||||
#
|
||||
# Usage:
|
||||
# architect-run.sh [projects/disinto.toml] # project config (default: disinto)
|
||||
#
|
||||
# Cron: 0 */6 * * * # every 6 hours
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
FACTORY_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# Accept project config from argument; default to disinto
|
||||
export PROJECT_TOML="${1:-$FACTORY_ROOT/projects/disinto.toml}"
|
||||
# shellcheck source=../lib/env.sh
|
||||
source "$FACTORY_ROOT/lib/env.sh"
|
||||
# Override FORGE_TOKEN with architect-bot's token (#747)
|
||||
FORGE_TOKEN="${FORGE_ARCHITECT_TOKEN:-${FORGE_TOKEN}}"
|
||||
# shellcheck source=../lib/formula-session.sh
|
||||
source "$FACTORY_ROOT/lib/formula-session.sh"
|
||||
# shellcheck source=../lib/worktree.sh
|
||||
source "$FACTORY_ROOT/lib/worktree.sh"
|
||||
# shellcheck source=../lib/guard.sh
|
||||
source "$FACTORY_ROOT/lib/guard.sh"
|
||||
# shellcheck source=../lib/agent-sdk.sh
|
||||
source "$FACTORY_ROOT/lib/agent-sdk.sh"
|
||||
|
||||
LOG_FILE="${DISINTO_LOG_DIR}/architect/architect.log"
|
||||
# shellcheck disable=SC2034 # consumed by agent-sdk.sh
|
||||
LOGFILE="$LOG_FILE"
|
||||
# shellcheck disable=SC2034 # consumed by agent-sdk.sh
|
||||
SID_FILE="/tmp/architect-session-${PROJECT_NAME}.sid"
|
||||
SCRATCH_FILE="/tmp/architect-${PROJECT_NAME}-scratch.md"
|
||||
WORKTREE="/tmp/${PROJECT_NAME}-architect-run"
|
||||
|
||||
# Override LOG_AGENT for consistent agent identification
|
||||
# shellcheck disable=SC2034 # consumed by agent-sdk.sh and env.sh log()
|
||||
LOG_AGENT="architect"
|
||||
|
||||
# Override log() to append to architect-specific log file
|
||||
# shellcheck disable=SC2034
|
||||
log() {
|
||||
local agent="${LOG_AGENT:-architect}"
|
||||
printf '[%s] %s: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$agent" "$*" >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
# ── Guards ────────────────────────────────────────────────────────────────
|
||||
check_active architect
|
||||
acquire_cron_lock "/tmp/architect-run.lock"
|
||||
memory_guard 2000
|
||||
|
||||
log "--- Architect run start ---"
|
||||
|
||||
# ── Resolve forge remote for git operations ─────────────────────────────
|
||||
resolve_forge_remote
|
||||
|
||||
# ── Resolve agent identity for .profile repo ────────────────────────────
|
||||
if [ -z "${AGENT_IDENTITY:-}" ] && [ -n "${FORGE_ARCHITECT_TOKEN:-}" ]; then
|
||||
AGENT_IDENTITY=$(curl -sf -H "Authorization: token ${FORGE_ARCHITECT_TOKEN}" \
|
||||
"${FORGE_URL:-http://localhost:3000}/api/v1/user" 2>/dev/null | jq -r '.login // empty' 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# ── Load formula + context ───────────────────────────────────────────────
|
||||
load_formula_or_profile "architect" "$FACTORY_ROOT/formulas/run-architect.toml" || exit 1
|
||||
build_context_block VISION.md AGENTS.md ops:prerequisites.md
|
||||
|
||||
# ── Prepare .profile context (lessons injection) ─────────────────────────
|
||||
formula_prepare_profile_context
|
||||
|
||||
# ── Build structural analysis graph ──────────────────────────────────────
|
||||
build_graph_section
|
||||
|
||||
# ── Read scratch file (compaction survival) ───────────────────────────────
|
||||
SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
|
||||
SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
|
||||
|
||||
# ── Build prompt ─────────────────────────────────────────────────────────
|
||||
build_sdk_prompt_footer
|
||||
|
||||
# Architect prompt: strategic decomposition of vision into sprints
|
||||
# See: architect/AGENTS.md for full role description
|
||||
# Pattern: heredoc function to avoid inline prompt construction
|
||||
# Note: Uses CONTEXT_BLOCK, GRAPH_SECTION, SCRATCH_CONTEXT from formula-session.sh
|
||||
# Architecture Decision: AD-003 — The runtime creates and destroys, the formula preserves.
|
||||
build_architect_prompt() {
|
||||
cat <<_PROMPT_EOF_
|
||||
You are the architect agent for ${FORGE_REPO}. Work through the formula below.
|
||||
|
||||
Your role: strategic decomposition of vision issues into development sprints.
|
||||
Propose sprints via PRs on the ops repo, converse with humans through PR comments,
|
||||
and file sub-issues after design forks are resolved.
|
||||
|
||||
## Project context
|
||||
${CONTEXT_BLOCK}
|
||||
${GRAPH_SECTION}
|
||||
${SCRATCH_CONTEXT}
|
||||
$(formula_lessons_block)
|
||||
## Formula
|
||||
${FORMULA_CONTENT}
|
||||
|
||||
${SCRATCH_INSTRUCTION}
|
||||
${PROMPT_FOOTER}
|
||||
_PROMPT_EOF_
|
||||
}
|
||||
|
||||
PROMPT=$(build_architect_prompt)
|
||||
|
||||
# ── Create worktree ──────────────────────────────────────────────────────
|
||||
formula_worktree_setup "$WORKTREE"
|
||||
|
||||
# ── Run agent ─────────────────────────────────────────────────────────────
|
||||
export CLAUDE_MODEL="sonnet"
|
||||
|
||||
agent_run --worktree "$WORKTREE" "$PROMPT"
|
||||
log "agent_run complete"
|
||||
|
||||
rm -f "$SCRATCH_FILE"
|
||||
|
||||
# Write journal entry post-session
|
||||
profile_write_journal "architect-run" "Architect run $(date -u +%Y-%m-%d)" "complete" "" || true
|
||||
|
||||
log "--- Architect run done ---"
|
||||
1450
bin/disinto
1450
bin/disinto
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
|||
<!-- last-reviewed: ac2beac361503c8712ecfc72be0401b5968cce4e -->
|
||||
<!-- last-reviewed: 9b91c6a5bdd83513ca262dd468ea14db675971c1 -->
|
||||
# Dev Agent
|
||||
|
||||
**Role**: Implement issues autonomously — write code, push branches, address
|
||||
|
|
@ -14,8 +14,9 @@ in-progress issues are also picked up. The direct-merge scan runs before the loc
|
|||
check so approved PRs get merged even while a dev-agent session is active.
|
||||
|
||||
**Key files**:
|
||||
- `dev/dev-poll.sh` — Cron scheduler: finds next ready issue, handles merge/rebase of approved PRs, tracks CI fix attempts. Formula guard skips issues labeled `formula`, `prediction/dismissed`, or `prediction/unreviewed`. **Race prevention**: checks issue assignee before claiming — skips if assigned to a different bot user. **Stale branch abandonment**: closes PRs and deletes branches that are behind `$PRIMARY_BRANCH` (restarts poll cycle for a fresh start). **Stale in-progress recovery**: on each poll cycle, scans for issues labeled `in-progress`. If the issue is assigned to `$BOT_USER` (this agent), sets `BLOCKED_BY_INPROGRESS=true` — my thread is busy. If assigned to another agent, logs and falls through (does not block). If no assignee, no open PR, and no agent lock file — removes `in-progress`, adds `blocked` with a human-triage comment. **Per-agent open-PR gate**: before starting new work, filters open waiting PRs to only those assigned to this agent (`$BOT_USER`). Other agents' PRs do not block this agent's pipeline (#358, #369).
|
||||
- `dev/dev-poll.sh` — Cron scheduler: finds next ready issue, handles merge/rebase of approved PRs, tracks CI fix attempts. Formula guard skips issues labeled `formula`, `action`, `prediction/dismissed`, or `prediction/unreviewed`. Also injects CI failures and review feedback into active tmux sessions via `_inject_into_session()` (uses `tmux load-buffer` + `paste-buffer` to handle multi-line text safely).
|
||||
- `dev/dev-agent.sh` — Orchestrator: claims issue, creates worktree + tmux session with interactive `claude`, monitors phase file, injects CI results and review feedback, merges on approval
|
||||
- `dev/phase-handler.sh` — Phase callback functions: `post_refusal_comment()`, `_on_phase_change()`, `build_phase_protocol_prompt()`. `do_merge()` detects already-merged PRs on HTTP 405 (race with dev-poll's pre-lock scan) and returns success instead of escalating. Sources `lib/mirrors.sh` and calls `mirror_push()` after every successful merge.
|
||||
- `dev/phase-test.sh` — Integration test for the phase protocol
|
||||
|
||||
**Environment variables consumed** (via `lib/env.sh` + project TOML):
|
||||
|
|
@ -32,7 +33,9 @@ check so approved PRs get merged even while a dev-agent session is active.
|
|||
|
||||
**Crash recovery**: on `PHASE:crashed` or non-zero exit, the worktree is **preserved** (not destroyed) for debugging. Location logged. Supervisor housekeeping removes stale crashed worktrees older than 24h.
|
||||
|
||||
**Lifecycle**: dev-poll.sh (`check_active dev`) → dev-agent.sh → tmux session → phase file
|
||||
**Rebase-before-push**: the phase protocol instructs Claude to `git fetch && git rebase` on `$PRIMARY_BRANCH` before every push (initial, CI fix, and review address). This avoids merge conflicts when main has advanced since branch creation. Uses `--force-with-lease` on CI/review fix pushes.
|
||||
|
||||
**Lifecycle**: dev-poll.sh (`check_active dev`) → dev-agent.sh → tmux `dev-{project}-{issue}` → phase file
|
||||
drives CI/review loop → merge + `mirror_push()` → close issue. On respawn after
|
||||
`PHASE:escalate`, the stale phase file is cleared first so the session starts
|
||||
clean; the reinject prompt tells Claude not to re-escalate for the same reason.
|
||||
|
|
|
|||
893
dev/dev-agent.sh
893
dev/dev-agent.sh
File diff suppressed because it is too large
Load diff
847
dev/dev-poll.sh
847
dev/dev-poll.sh
File diff suppressed because it is too large
Load diff
809
dev/phase-handler.sh
Normal file
809
dev/phase-handler.sh
Normal file
|
|
@ -0,0 +1,809 @@
|
|||
#!/usr/bin/env bash
|
||||
# dev/phase-handler.sh — Phase callback functions for dev-agent.sh
|
||||
#
|
||||
# Source this file from agent orchestrators after lib/agent-session.sh is loaded.
|
||||
# Defines: post_refusal_comment(), _on_phase_change(), build_phase_protocol_prompt()
|
||||
#
|
||||
# Required globals (set by calling agent before or after sourcing):
|
||||
# ISSUE, FORGE_TOKEN, API, FORGE_WEB, PROJECT_NAME, FACTORY_ROOT
|
||||
# BRANCH, PHASE_FILE, WORKTREE, IMPL_SUMMARY_FILE
|
||||
# PRIMARY_BRANCH, SESSION_NAME, LOGFILE, ISSUE_TITLE
|
||||
# WOODPECKER_REPO_ID, WOODPECKER_TOKEN, WOODPECKER_SERVER
|
||||
#
|
||||
# Globals with defaults (agents can override after sourcing):
|
||||
# PR_NUMBER, CI_POLL_TIMEOUT, MAX_CI_FIXES, MAX_REVIEW_ROUNDS,
|
||||
# REVIEW_POLL_TIMEOUT, CI_RETRY_COUNT, CI_FIX_COUNT, REVIEW_ROUND,
|
||||
# CLAIMED, PHASE_POLL_INTERVAL
|
||||
#
|
||||
# Calls back to agent-defined helpers:
|
||||
# cleanup_worktree(), cleanup_labels(), status(), log()
|
||||
#
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2154 # globals are set in dev-agent.sh before calling
|
||||
# shellcheck disable=SC2034 # CLAIMED is read by cleanup() in dev-agent.sh
|
||||
|
||||
# Load secret scanner for redacting tmux output before posting to issues
|
||||
# shellcheck source=../lib/secret-scan.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/../lib/secret-scan.sh"
|
||||
|
||||
# Load shared CI helpers (is_infra_step, classify_pipeline_failure, etc.)
|
||||
# shellcheck source=../lib/ci-helpers.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/../lib/ci-helpers.sh"
|
||||
|
||||
# Load mirror push helper
|
||||
# shellcheck source=../lib/mirrors.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/../lib/mirrors.sh"
|
||||
|
||||
# --- Default globals (agents can override after sourcing) ---
|
||||
: "${CI_POLL_TIMEOUT:=1800}"
|
||||
: "${REVIEW_POLL_TIMEOUT:=10800}"
|
||||
: "${MAX_CI_FIXES:=3}"
|
||||
: "${MAX_REVIEW_ROUNDS:=5}"
|
||||
: "${CI_RETRY_COUNT:=0}"
|
||||
: "${CI_FIX_COUNT:=0}"
|
||||
: "${REVIEW_ROUND:=0}"
|
||||
: "${PR_NUMBER:=}"
|
||||
: "${CLAIMED:=false}"
|
||||
: "${PHASE_POLL_INTERVAL:=30}"
|
||||
|
||||
# --- Post diagnostic comment + label issue as blocked ---
|
||||
# Captures tmux pane output, posts a structured comment on the issue, removes
|
||||
# in-progress label, and adds the "blocked" label.
|
||||
#
|
||||
# Args: reason [session_name]
|
||||
# Uses globals: ISSUE, SESSION_NAME, PR_NUMBER, FORGE_TOKEN, API
|
||||
post_blocked_diagnostic() {
|
||||
local reason="$1"
|
||||
local session="${2:-${SESSION_NAME:-}}"
|
||||
|
||||
# Capture last 50 lines from tmux pane (before kill)
|
||||
local tmux_output=""
|
||||
if [ -n "$session" ] && tmux has-session -t "$session" 2>/dev/null; then
|
||||
tmux_output=$(tmux capture-pane -p -t "$session" -S -50 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# Redact any secrets from tmux output before posting to issue
|
||||
if [ -n "$tmux_output" ]; then
|
||||
tmux_output=$(redact_secrets "$tmux_output")
|
||||
fi
|
||||
|
||||
# Build diagnostic comment body
|
||||
local comment
|
||||
comment="### Session failure diagnostic
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Exit reason | \`${reason}\` |
|
||||
| Timestamp | \`$(date -u +%Y-%m-%dT%H:%M:%SZ)\` |"
|
||||
[ -n "${PR_NUMBER:-}" ] && [ "${PR_NUMBER:-0}" != "0" ] && \
|
||||
comment="${comment}
|
||||
| PR | #${PR_NUMBER} |"
|
||||
|
||||
if [ -n "$tmux_output" ]; then
|
||||
comment="${comment}
|
||||
|
||||
<details><summary>Last 50 lines from tmux pane</summary>
|
||||
|
||||
\`\`\`
|
||||
${tmux_output}
|
||||
\`\`\`
|
||||
</details>"
|
||||
fi
|
||||
|
||||
# Post comment to issue
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE}/comments" \
|
||||
-d "$(jq -nc --arg b "$comment" '{body:$b}')" >/dev/null 2>&1 || true
|
||||
|
||||
# Remove in-progress, add blocked
|
||||
cleanup_labels
|
||||
local blocked_id
|
||||
blocked_id=$(ensure_blocked_label_id)
|
||||
if [ -n "$blocked_id" ]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE}/labels" \
|
||||
-d "{\"labels\":[${blocked_id}]}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
CLAIMED=false
|
||||
_BLOCKED_POSTED=true
|
||||
}
|
||||
|
||||
# --- Build phase protocol prompt (shared across agents) ---
|
||||
# Generates the phase-signaling instructions for Claude prompts.
|
||||
# Args: phase_file summary_file branch [remote]
|
||||
# Output: The protocol text (stdout)
|
||||
build_phase_protocol_prompt() {
|
||||
local _pf="$1" _sf="$2" _br="$3" _remote="${4:-${FORGE_REMOTE:-origin}}"
|
||||
cat <<_PHASE_PROTOCOL_EOF_
|
||||
## Phase-Signaling Protocol (REQUIRED)
|
||||
|
||||
You are running in a persistent tmux session managed by an orchestrator.
|
||||
Communicate progress by writing to the phase file. The orchestrator watches
|
||||
this file and injects events (CI results, review feedback) back into this session.
|
||||
|
||||
### Key files
|
||||
\`\`\`
|
||||
PHASE_FILE="${_pf}"
|
||||
SUMMARY_FILE="${_sf}"
|
||||
\`\`\`
|
||||
|
||||
### Phase transitions — write these exactly:
|
||||
|
||||
**After committing and pushing your branch:**
|
||||
\`\`\`bash
|
||||
# Rebase on target branch before push to avoid merge conflicts
|
||||
git fetch ${_remote} ${PRIMARY_BRANCH} && git rebase ${_remote}/${PRIMARY_BRANCH}
|
||||
git push ${_remote} ${_br}
|
||||
# Write a short summary of what you implemented:
|
||||
printf '%s' "<your summary>" > "\${SUMMARY_FILE}"
|
||||
# Signal the orchestrator to create the PR and watch for CI:
|
||||
echo "PHASE:awaiting_ci" > "${_pf}"
|
||||
\`\`\`
|
||||
Then STOP and wait. The orchestrator will inject CI results.
|
||||
|
||||
**When you receive a "CI passed" injection:**
|
||||
\`\`\`bash
|
||||
echo "PHASE:awaiting_review" > "${_pf}"
|
||||
\`\`\`
|
||||
Then STOP and wait. The orchestrator will inject review feedback.
|
||||
|
||||
**When you receive a "CI failed:" injection:**
|
||||
Fix the CI issue, then rebase on target branch and push:
|
||||
\`\`\`bash
|
||||
git fetch ${_remote} ${PRIMARY_BRANCH} && git rebase ${_remote}/${PRIMARY_BRANCH}
|
||||
git push --force-with-lease ${_remote} ${_br}
|
||||
echo "PHASE:awaiting_ci" > "${_pf}"
|
||||
\`\`\`
|
||||
Then STOP and wait.
|
||||
|
||||
**When you receive a "Review: REQUEST_CHANGES" injection:**
|
||||
Address ALL review feedback, then rebase on target branch and push:
|
||||
\`\`\`bash
|
||||
git fetch ${_remote} ${PRIMARY_BRANCH} && git rebase ${_remote}/${PRIMARY_BRANCH}
|
||||
git push --force-with-lease ${_remote} ${_br}
|
||||
echo "PHASE:awaiting_ci" > "${_pf}"
|
||||
\`\`\`
|
||||
(CI runs again after each push — always write awaiting_ci, not awaiting_review)
|
||||
|
||||
**When you need human help (CI exhausted, merge blocked, stuck on a decision):**
|
||||
\`\`\`bash
|
||||
printf 'PHASE:escalate\nReason: %s\n' "describe what you need" > "${_pf}"
|
||||
\`\`\`
|
||||
Then STOP and wait. A human will review and respond via the forge.
|
||||
|
||||
**On unrecoverable failure:**
|
||||
\`\`\`bash
|
||||
printf 'PHASE:failed\nReason: %s\n' "describe what failed" > "${_pf}"
|
||||
\`\`\`
|
||||
_PHASE_PROTOCOL_EOF_
|
||||
}
|
||||
|
||||
# --- Merge helper ---
|
||||
# do_merge — attempt to merge PR via forge API.
|
||||
# Args: pr_num
|
||||
# Returns:
|
||||
# 0 = merged successfully
|
||||
# 1 = other failure (conflict, network error, etc.)
|
||||
# 2 = not enough approvals (HTTP 405) — PHASE:escalate already written
|
||||
do_merge() {
|
||||
local pr_num="$1"
|
||||
local merge_response merge_http_code merge_body
|
||||
merge_response=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${API}/pulls/${pr_num}/merge" \
|
||||
-d '{"Do":"merge","delete_branch_after_merge":true}') || true
|
||||
merge_http_code=$(echo "$merge_response" | tail -1)
|
||||
merge_body=$(echo "$merge_response" | sed '$d')
|
||||
|
||||
if [ "$merge_http_code" = "200" ] || [ "$merge_http_code" = "204" ]; then
|
||||
log "do_merge: PR #${pr_num} merged (HTTP ${merge_http_code})"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# HTTP 405 — could be "merge requirements not met" OR "already merged" (race with dev-poll).
|
||||
# Before escalating, check whether the PR was already merged by another agent.
|
||||
if [ "$merge_http_code" = "405" ]; then
|
||||
local pr_state
|
||||
pr_state=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${API}/pulls/${pr_num}" | jq -r '.merged // false') || pr_state="false"
|
||||
if [ "$pr_state" = "true" ]; then
|
||||
log "do_merge: PR #${pr_num} already merged (detected after HTTP 405) — treating as success"
|
||||
return 0
|
||||
fi
|
||||
log "do_merge: PR #${pr_num} blocked — merge requirements not met (HTTP 405): ${merge_body:0:200}"
|
||||
printf 'PHASE:escalate\nReason: %s\n' \
|
||||
"PR #${pr_num} merge blocked — merge requirements not met (HTTP 405): ${merge_body:0:200}" \
|
||||
> "$PHASE_FILE"
|
||||
return 2
|
||||
fi
|
||||
|
||||
log "do_merge: PR #${pr_num} merge failed (HTTP ${merge_http_code}): ${merge_body:0:200}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- Refusal comment helper ---
|
||||
post_refusal_comment() {
|
||||
local emoji="$1" title="$2" body="$3"
|
||||
local last_has_title
|
||||
last_has_title=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${API}/issues/${ISSUE}/comments?limit=5" | \
|
||||
jq -r --arg t "Dev-agent: ${title}" '[.[] | .body // ""] | any(contains($t)) | tostring') || true
|
||||
if [ "$last_has_title" = "true" ]; then
|
||||
log "skipping duplicate refusal comment: ${title}"
|
||||
return 0
|
||||
fi
|
||||
local comment
|
||||
comment="${emoji} **Dev-agent: ${title}**
|
||||
|
||||
${body}
|
||||
|
||||
---
|
||||
*Automated assessment by dev-agent · $(date -u '+%Y-%m-%d %H:%M UTC')*"
|
||||
printf '%s' "$comment" > "/tmp/refusal-comment.txt"
|
||||
jq -Rs '{body: .}' < "/tmp/refusal-comment.txt" > "/tmp/refusal-comment.json"
|
||||
curl -sf -o /dev/null -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE}/comments" \
|
||||
--data-binary @"/tmp/refusal-comment.json" 2>/dev/null || \
|
||||
log "WARNING: failed to post refusal comment"
|
||||
rm -f "/tmp/refusal-comment.txt" "/tmp/refusal-comment.json"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# PHASE DISPATCH CALLBACK
|
||||
# =============================================================================
|
||||
|
||||
# _on_phase_change — Phase dispatch callback for monitor_phase_loop
|
||||
# Receives the current phase as $1.
|
||||
# Returns 0 to continue the loop, 1 to break (terminal phase reached).
|
||||
_on_phase_change() {
|
||||
local phase="$1"
|
||||
|
||||
# ── PHASE: awaiting_ci ──────────────────────────────────────────────────────
|
||||
if [ "$phase" = "PHASE:awaiting_ci" ]; then
|
||||
# Release session lock — Claude is idle during CI polling (#724)
|
||||
session_lock_release
|
||||
|
||||
# Create PR if not yet created
|
||||
if [ -z "${PR_NUMBER:-}" ]; then
|
||||
status "creating PR for issue #${ISSUE}"
|
||||
IMPL_SUMMARY=""
|
||||
if [ -f "$IMPL_SUMMARY_FILE" ]; then
|
||||
# Don't treat refusal JSON as a PR summary
|
||||
if ! jq -e '.status' < "$IMPL_SUMMARY_FILE" >/dev/null 2>&1; then
|
||||
IMPL_SUMMARY=$(head -c 4000 "$IMPL_SUMMARY_FILE")
|
||||
fi
|
||||
fi
|
||||
|
||||
printf 'Fixes #%s\n\n## Changes\n%s' "$ISSUE" "$IMPL_SUMMARY" > "/tmp/pr-body-${ISSUE}.txt"
|
||||
jq -n \
|
||||
--arg title "fix: ${ISSUE_TITLE} (#${ISSUE})" \
|
||||
--rawfile body "/tmp/pr-body-${ISSUE}.txt" \
|
||||
--arg head "$BRANCH" \
|
||||
--arg base "${PRIMARY_BRANCH}" \
|
||||
'{title: $title, body: $body, head: $head, base: $base}' > "/tmp/pr-request-${ISSUE}.json"
|
||||
|
||||
PR_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/pulls" \
|
||||
--data-binary @"/tmp/pr-request-${ISSUE}.json")
|
||||
|
||||
PR_HTTP_CODE=$(echo "$PR_RESPONSE" | tail -1)
|
||||
PR_RESPONSE_BODY=$(echo "$PR_RESPONSE" | sed '$d')
|
||||
rm -f "/tmp/pr-body-${ISSUE}.txt" "/tmp/pr-request-${ISSUE}.json"
|
||||
|
||||
if [ "$PR_HTTP_CODE" = "201" ] || [ "$PR_HTTP_CODE" = "200" ]; then
|
||||
PR_NUMBER=$(echo "$PR_RESPONSE_BODY" | jq -r '.number')
|
||||
log "created PR #${PR_NUMBER}"
|
||||
elif [ "$PR_HTTP_CODE" = "409" ]; then
|
||||
# PR already exists (race condition) — find it
|
||||
FOUND_PR=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${API}/pulls?state=open&limit=20" | \
|
||||
jq -r --arg branch "$BRANCH" \
|
||||
'.[] | select(.head.ref == $branch) | .number' | head -1) || true
|
||||
if [ -n "$FOUND_PR" ]; then
|
||||
PR_NUMBER="$FOUND_PR"
|
||||
log "PR already exists: #${PR_NUMBER}"
|
||||
else
|
||||
log "ERROR: PR creation got 409 but no existing PR found"
|
||||
agent_inject_into_session "$SESSION_NAME" "ERROR: Could not create PR (HTTP 409, no existing PR found). Check the forge API. Retry by writing PHASE:awaiting_ci again after verifying the branch was pushed."
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
log "ERROR: PR creation failed (HTTP ${PR_HTTP_CODE})"
|
||||
agent_inject_into_session "$SESSION_NAME" "ERROR: Could not create PR (HTTP ${PR_HTTP_CODE}). Check branch was pushed: git push ${FORGE_REMOTE:-origin} ${BRANCH}. Then write PHASE:awaiting_ci again."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# No CI configured? Treat as success immediately
|
||||
if [ "${WOODPECKER_REPO_ID:-2}" = "0" ]; then
|
||||
log "no CI configured — treating as passed"
|
||||
agent_inject_into_session "$SESSION_NAME" "CI passed on PR #${PR_NUMBER} (no CI configured for this project).
|
||||
Write PHASE:awaiting_review to the phase file, then stop and wait for review feedback."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Poll CI until done or timeout
|
||||
status "waiting for CI on PR #${PR_NUMBER}"
|
||||
CI_CURRENT_SHA=$(git -C "${WORKTREE}" rev-parse HEAD 2>/dev/null || \
|
||||
curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${API}/pulls/${PR_NUMBER}" | jq -r '.head.sha')
|
||||
|
||||
CI_DONE=false
|
||||
CI_STATE="unknown"
|
||||
CI_POLL_ELAPSED=0
|
||||
while [ "$CI_POLL_ELAPSED" -lt "$CI_POLL_TIMEOUT" ]; do
|
||||
sleep 30
|
||||
CI_POLL_ELAPSED=$(( CI_POLL_ELAPSED + 30 ))
|
||||
|
||||
# Check session still alive during CI wait (exit_marker + tmux fallback)
|
||||
if [ -f "/tmp/claude-exited-${SESSION_NAME}.ts" ] || ! tmux has-session -t "${SESSION_NAME}" 2>/dev/null; then
|
||||
log "session died during CI wait"
|
||||
break
|
||||
fi
|
||||
|
||||
# Re-fetch HEAD — Claude may have pushed new commits since loop started
|
||||
CI_CURRENT_SHA=$(git -C "${WORKTREE}" rev-parse HEAD 2>/dev/null || echo "$CI_CURRENT_SHA")
|
||||
|
||||
CI_STATE=$(ci_commit_status "$CI_CURRENT_SHA")
|
||||
if [ "$CI_STATE" = "success" ] || [ "$CI_STATE" = "failure" ] || [ "$CI_STATE" = "error" ]; then
|
||||
CI_DONE=true
|
||||
[ "$CI_STATE" = "success" ] && CI_FIX_COUNT=0
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if ! $CI_DONE; then
|
||||
log "TIMEOUT: CI didn't complete in ${CI_POLL_TIMEOUT}s"
|
||||
agent_inject_into_session "$SESSION_NAME" "CI TIMEOUT: CI did not complete within 30 minutes for PR #${PR_NUMBER} (SHA: ${CI_CURRENT_SHA:0:7}). This may be an infrastructure issue. Write PHASE:escalate if you cannot proceed."
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "CI: ${CI_STATE}"
|
||||
|
||||
if [ "$CI_STATE" = "success" ]; then
|
||||
agent_inject_into_session "$SESSION_NAME" "CI passed on PR #${PR_NUMBER}.
|
||||
Write PHASE:awaiting_review to the phase file, then stop and wait for review feedback:
|
||||
echo \"PHASE:awaiting_review\" > \"${PHASE_FILE}\""
|
||||
else
|
||||
# Fetch CI error details
|
||||
PIPELINE_NUM=$(ci_pipeline_number "$CI_CURRENT_SHA")
|
||||
|
||||
FAILED_STEP=""
|
||||
FAILED_EXIT=""
|
||||
IS_INFRA=false
|
||||
if [ -n "$PIPELINE_NUM" ]; then
|
||||
FAILED_INFO=$(curl -sf \
|
||||
-H "Authorization: Bearer ${WOODPECKER_TOKEN}" \
|
||||
"${WOODPECKER_SERVER}/api/repos/${WOODPECKER_REPO_ID}/pipelines/${PIPELINE_NUM}" | \
|
||||
jq -r '.workflows[]?.children[]? | select(.state=="failure") | "\(.name)|\(.exit_code)"' | head -1 || true)
|
||||
FAILED_STEP=$(echo "$FAILED_INFO" | cut -d'|' -f1)
|
||||
FAILED_EXIT=$(echo "$FAILED_INFO" | cut -d'|' -f2)
|
||||
fi
|
||||
|
||||
log "CI failed: step=${FAILED_STEP:-unknown} exit=${FAILED_EXIT:-?}"
|
||||
|
||||
if [ -n "$FAILED_STEP" ] && is_infra_step "$FAILED_STEP" "${FAILED_EXIT:-0}" >/dev/null 2>&1; then
|
||||
IS_INFRA=true
|
||||
fi
|
||||
|
||||
if [ "$IS_INFRA" = true ] && [ "${CI_RETRY_COUNT:-0}" -lt 1 ]; then
|
||||
CI_RETRY_COUNT=$(( CI_RETRY_COUNT + 1 ))
|
||||
log "infra failure — retrigger CI (retry ${CI_RETRY_COUNT})"
|
||||
(cd "$WORKTREE" && git commit --allow-empty \
|
||||
-m "ci: retrigger after infra failure (#${ISSUE})" --no-verify 2>&1 | tail -1)
|
||||
# Rebase on target branch before push to avoid merge conflicts
|
||||
if ! (cd "$WORKTREE" && \
|
||||
git fetch "${FORGE_REMOTE:-origin}" "${PRIMARY_BRANCH}" 2>/dev/null && \
|
||||
git rebase "${FORGE_REMOTE:-origin}/${PRIMARY_BRANCH}" 2>&1 | tail -5); then
|
||||
log "rebase conflict detected — aborting, agent must resolve"
|
||||
(cd "$WORKTREE" && git rebase --abort 2>/dev/null || git reset --hard HEAD 2>/dev/null) || true
|
||||
agent_inject_into_session "$SESSION_NAME" "REBASE CONFLICT: Cannot rebase onto ${PRIMARY_BRANCH} automatically.
|
||||
|
||||
Please resolve merge conflicts manually:
|
||||
1. Check conflict status: git status
|
||||
2. Resolve conflicts in the conflicted files
|
||||
3. Stage resolved files: git add <files>
|
||||
4. Continue rebase: git rebase --continue
|
||||
|
||||
If you cannot resolve conflicts, abort: git rebase --abort
|
||||
Then write PHASE:escalate with a reason."
|
||||
return 0
|
||||
fi
|
||||
# Rebase succeeded — push the result
|
||||
(cd "$WORKTREE" && git push --force-with-lease "${FORGE_REMOTE:-origin}" "$BRANCH" 2>&1 | tail -3)
|
||||
# Touch phase file so we recheck CI on the new SHA
|
||||
# Do NOT update LAST_PHASE_MTIME here — let the main loop detect the fresh mtime
|
||||
touch "$PHASE_FILE"
|
||||
CI_CURRENT_SHA=$(git -C "${WORKTREE}" rev-parse HEAD 2>/dev/null || true)
|
||||
return 0
|
||||
fi
|
||||
|
||||
CI_FIX_COUNT=$(( CI_FIX_COUNT + 1 ))
|
||||
_ci_pipeline_url="${WOODPECKER_SERVER}/repos/${WOODPECKER_REPO_ID}/pipeline/${PIPELINE_NUM:-0}"
|
||||
if [ "$CI_FIX_COUNT" -gt "$MAX_CI_FIXES" ]; then
|
||||
log "CI failure not recoverable after ${CI_FIX_COUNT} fix attempts — escalating"
|
||||
printf 'PHASE:escalate\nReason: ci_exhausted after %d attempts (step: %s)\n' "$CI_FIX_COUNT" "${FAILED_STEP:-unknown}" > "$PHASE_FILE"
|
||||
# Do NOT update LAST_PHASE_MTIME here — let the main loop detect PHASE:escalate
|
||||
return 0
|
||||
fi
|
||||
|
||||
CI_ERROR_LOG=""
|
||||
if [ -n "$PIPELINE_NUM" ]; then
|
||||
CI_ERROR_LOG=$(bash "${FACTORY_ROOT}/lib/ci-debug.sh" failures "$PIPELINE_NUM" 2>/dev/null | tail -80 | head -c 8000 || echo "")
|
||||
fi
|
||||
|
||||
# Save CI result for crash recovery
|
||||
printf 'CI failed (attempt %d/%d)\nStep: %s\nExit: %s\n\n%s' \
|
||||
"$CI_FIX_COUNT" "$MAX_CI_FIXES" "${FAILED_STEP:-unknown}" "${FAILED_EXIT:-?}" "$CI_ERROR_LOG" \
|
||||
> "/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt" 2>/dev/null || true
|
||||
|
||||
agent_inject_into_session "$SESSION_NAME" "CI failed on PR #${PR_NUMBER} (attempt ${CI_FIX_COUNT}/${MAX_CI_FIXES}).
|
||||
|
||||
Failed step: ${FAILED_STEP:-unknown} (exit code ${FAILED_EXIT:-?}, pipeline #${PIPELINE_NUM:-?})
|
||||
|
||||
CI debug tool:
|
||||
bash ${FACTORY_ROOT}/lib/ci-debug.sh failures ${PIPELINE_NUM:-0}
|
||||
bash ${FACTORY_ROOT}/lib/ci-debug.sh logs ${PIPELINE_NUM:-0} <step-name>
|
||||
|
||||
Error snippet:
|
||||
${CI_ERROR_LOG:-No logs available. Use ci-debug.sh to query the pipeline.}
|
||||
|
||||
Instructions:
|
||||
1. Run ci-debug.sh failures to get the full error output.
|
||||
2. Read the failing test file(s) — understand what the tests EXPECT.
|
||||
3. Fix the root cause — do NOT weaken tests.
|
||||
4. Rebase on target branch and push: git fetch ${FORGE_REMOTE:-origin} ${PRIMARY_BRANCH} && git rebase ${FORGE_REMOTE:-origin}/${PRIMARY_BRANCH}
|
||||
git push --force-with-lease ${FORGE_REMOTE:-origin} ${BRANCH}
|
||||
5. Write: echo \"PHASE:awaiting_ci\" > \"${PHASE_FILE}\"
|
||||
6. Stop and wait."
|
||||
fi
|
||||
|
||||
# ── PHASE: awaiting_review ──────────────────────────────────────────────────
|
||||
elif [ "$phase" = "PHASE:awaiting_review" ]; then
|
||||
# Release session lock — Claude is idle during review wait (#724)
|
||||
session_lock_release
|
||||
status "waiting for review on PR #${PR_NUMBER:-?}"
|
||||
CI_FIX_COUNT=0 # Reset CI fix budget for this review cycle
|
||||
|
||||
if [ -z "${PR_NUMBER:-}" ]; then
|
||||
log "WARNING: awaiting_review but PR_NUMBER unknown — searching for PR"
|
||||
FOUND_PR=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${API}/pulls?state=open&limit=20" | \
|
||||
jq -r --arg branch "$BRANCH" \
|
||||
'.[] | select(.head.ref == $branch) | .number' | head -1) || true
|
||||
if [ -n "$FOUND_PR" ]; then
|
||||
PR_NUMBER="$FOUND_PR"
|
||||
log "found PR #${PR_NUMBER}"
|
||||
else
|
||||
agent_inject_into_session "$SESSION_NAME" "ERROR: Cannot find open PR for branch ${BRANCH}. Did you push? Verify with git status and git push ${FORGE_REMOTE:-origin} ${BRANCH}, then write PHASE:awaiting_ci."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
REVIEW_POLL_ELAPSED=0
|
||||
REVIEW_FOUND=false
|
||||
while [ "$REVIEW_POLL_ELAPSED" -lt "$REVIEW_POLL_TIMEOUT" ]; do
|
||||
sleep 300 # 5 min between review checks
|
||||
REVIEW_POLL_ELAPSED=$(( REVIEW_POLL_ELAPSED + 300 ))
|
||||
|
||||
# Check session still alive (exit_marker + tmux fallback)
|
||||
if [ -f "/tmp/claude-exited-${SESSION_NAME}.ts" ] || ! tmux has-session -t "${SESSION_NAME}" 2>/dev/null; then
|
||||
log "session died during review wait"
|
||||
REVIEW_FOUND=false
|
||||
break
|
||||
fi
|
||||
|
||||
# Check if phase was updated while we wait (e.g., Claude reacted to something)
|
||||
NEW_MTIME=$(stat -c %Y "$PHASE_FILE" 2>/dev/null || echo 0)
|
||||
if [ "$NEW_MTIME" -gt "$LAST_PHASE_MTIME" ]; then
|
||||
log "phase file updated during review wait — re-entering main loop"
|
||||
# Do NOT update LAST_PHASE_MTIME here — leave it stale so the outer
|
||||
# loop detects the change on its next tick and dispatches the new phase.
|
||||
REVIEW_FOUND=true # Prevent timeout injection
|
||||
# Clean up review-poll sentinel if it exists (session already advanced)
|
||||
rm -f "/tmp/review-injected-${PROJECT_NAME}-${PR_NUMBER}"
|
||||
break
|
||||
fi
|
||||
|
||||
REVIEW_SHA=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${API}/pulls/${PR_NUMBER}" | jq -r '.head.sha') || true
|
||||
REVIEW_COMMENT=$(forge_api_all "/issues/${PR_NUMBER}/comments" | \
|
||||
jq -r --arg sha "$REVIEW_SHA" \
|
||||
'[.[] | select(.body | contains("<!-- reviewed: " + $sha))] | last // empty') || true
|
||||
|
||||
if [ -n "$REVIEW_COMMENT" ] && [ "$REVIEW_COMMENT" != "null" ]; then
|
||||
REVIEW_TEXT=$(echo "$REVIEW_COMMENT" | jq -r '.body')
|
||||
|
||||
# Skip error reviews — they have no verdict
|
||||
if echo "$REVIEW_TEXT" | grep -q "review-error\|Review — Error"; then
|
||||
log "review was an error, waiting for re-review"
|
||||
continue
|
||||
fi
|
||||
|
||||
VERDICT=$(echo "$REVIEW_TEXT" | grep -oP '\*\*(APPROVE|REQUEST_CHANGES|DISCUSS)\*\*' | head -1 | tr -d '*' || true)
|
||||
log "review verdict: ${VERDICT:-unknown}"
|
||||
|
||||
# Also check formal forge reviews
|
||||
if [ -z "$VERDICT" ]; then
|
||||
VERDICT=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${API}/pulls/${PR_NUMBER}/reviews" | \
|
||||
jq -r '[.[] | select(.stale == false)] | last | .state // empty' || true)
|
||||
if [ "$VERDICT" = "APPROVED" ]; then
|
||||
VERDICT="APPROVE"
|
||||
elif [ "$VERDICT" != "REQUEST_CHANGES" ]; then
|
||||
VERDICT=""
|
||||
fi
|
||||
[ -n "$VERDICT" ] && log "verdict from formal review: $VERDICT"
|
||||
fi
|
||||
|
||||
# Skip injection if review-poll.sh already injected (sentinel present).
|
||||
# Exception: APPROVE always falls through so do_merge() runs even when
|
||||
# review-poll injected first — prevents Claude writing PHASE:done on a
|
||||
# failed merge without the orchestrator detecting the error.
|
||||
REVIEW_SENTINEL="/tmp/review-injected-${PROJECT_NAME}-${PR_NUMBER}"
|
||||
if [ -n "$VERDICT" ] && [ -f "$REVIEW_SENTINEL" ] && [ "$VERDICT" != "APPROVE" ]; then
|
||||
log "review already injected by review-poll (sentinel exists) — skipping"
|
||||
rm -f "$REVIEW_SENTINEL"
|
||||
REVIEW_FOUND=true
|
||||
break
|
||||
fi
|
||||
rm -f "$REVIEW_SENTINEL" # consume sentinel before APPROVE handling below
|
||||
|
||||
if [ "$VERDICT" = "APPROVE" ]; then
|
||||
REVIEW_FOUND=true
|
||||
_merge_rc=0; do_merge "$PR_NUMBER" || _merge_rc=$?
|
||||
if [ "$_merge_rc" -eq 0 ]; then
|
||||
# Merge succeeded — close issue and signal done
|
||||
curl -sf -X PATCH \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${API}/issues/${ISSUE}" \
|
||||
-d '{"state":"closed"}' >/dev/null 2>&1 || true
|
||||
# Pull merged primary branch and push to mirrors
|
||||
git -C "$PROJECT_REPO_ROOT" fetch "${FORGE_REMOTE:-origin}" "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
git -C "$PROJECT_REPO_ROOT" checkout "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
git -C "$PROJECT_REPO_ROOT" pull --ff-only "${FORGE_REMOTE:-origin}" "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
mirror_push
|
||||
printf 'PHASE:done\n' > "$PHASE_FILE"
|
||||
elif [ "$_merge_rc" -ne 2 ]; then
|
||||
# Other merge failure (conflict, etc.) — delegate to Claude for rebase + retry
|
||||
agent_inject_into_session "$SESSION_NAME" "Approved! PR #${PR_NUMBER} has been approved, but the merge failed (likely conflicts).
|
||||
|
||||
Rebase onto ${PRIMARY_BRANCH} and push:
|
||||
git fetch ${FORGE_REMOTE:-origin} ${PRIMARY_BRANCH} && git rebase ${FORGE_REMOTE:-origin}/${PRIMARY_BRANCH}
|
||||
git push --force-with-lease ${FORGE_REMOTE:-origin} ${BRANCH}
|
||||
echo \"PHASE:awaiting_ci\" > \"${PHASE_FILE}\"
|
||||
|
||||
Do NOT merge or close the issue — the orchestrator handles that after CI passes.
|
||||
If rebase repeatedly fails, write PHASE:escalate with a reason."
|
||||
fi
|
||||
# _merge_rc=2: PHASE:escalate already written by do_merge()
|
||||
break
|
||||
|
||||
elif [ "$VERDICT" = "REQUEST_CHANGES" ] || [ "$VERDICT" = "DISCUSS" ]; then
|
||||
REVIEW_ROUND=$(( REVIEW_ROUND + 1 ))
|
||||
if [ "$REVIEW_ROUND" -ge "$MAX_REVIEW_ROUNDS" ]; then
|
||||
log "hit max review rounds (${MAX_REVIEW_ROUNDS})"
|
||||
log "PR #${PR_NUMBER}: hit ${MAX_REVIEW_ROUNDS} review rounds, needs human attention"
|
||||
fi
|
||||
REVIEW_FOUND=true
|
||||
agent_inject_into_session "$SESSION_NAME" "Review feedback (round ${REVIEW_ROUND}) on PR #${PR_NUMBER}:
|
||||
|
||||
${REVIEW_TEXT}
|
||||
|
||||
Instructions:
|
||||
1. Address each piece of feedback carefully.
|
||||
2. Run lint and tests when done.
|
||||
3. Rebase on target branch and push: git fetch ${FORGE_REMOTE:-origin} ${PRIMARY_BRANCH} && git rebase ${FORGE_REMOTE:-origin}/${PRIMARY_BRANCH}
|
||||
git push --force-with-lease ${FORGE_REMOTE:-origin} ${BRANCH}
|
||||
4. Write: echo \"PHASE:awaiting_ci\" > \"${PHASE_FILE}\"
|
||||
5. Stop and wait for the next CI result."
|
||||
log "review REQUEST_CHANGES received (round ${REVIEW_ROUND})"
|
||||
break
|
||||
|
||||
else
|
||||
# No verdict found in comment or formal review — keep waiting
|
||||
log "review comment found but no verdict, continuing to wait"
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if PR was merged or closed externally
|
||||
PR_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${API}/pulls/${PR_NUMBER}") || true
|
||||
PR_STATE=$(echo "$PR_JSON" | jq -r '.state // "unknown"')
|
||||
PR_MERGED=$(echo "$PR_JSON" | jq -r '.merged // false')
|
||||
if [ "$PR_STATE" != "open" ]; then
|
||||
if [ "$PR_MERGED" = "true" ]; then
|
||||
log "PR #${PR_NUMBER} was merged externally"
|
||||
curl -sf -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE}" -d '{"state":"closed"}' >/dev/null 2>&1 || true
|
||||
cleanup_labels
|
||||
agent_kill_session "$SESSION_NAME"
|
||||
cleanup_worktree
|
||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "${SCRATCH_FILE:-}"
|
||||
exit 0
|
||||
else
|
||||
log "PR #${PR_NUMBER} was closed WITHOUT merge — NOT closing issue"
|
||||
cleanup_labels
|
||||
agent_kill_session "$SESSION_NAME"
|
||||
cleanup_worktree
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
log "waiting for review on PR #${PR_NUMBER} (${REVIEW_POLL_ELAPSED}s elapsed)"
|
||||
done
|
||||
|
||||
if ! $REVIEW_FOUND && [ "$REVIEW_POLL_ELAPSED" -ge "$REVIEW_POLL_TIMEOUT" ]; then
|
||||
log "TIMEOUT: no review after 3h"
|
||||
agent_inject_into_session "$SESSION_NAME" "TIMEOUT: No review received after 3 hours for PR #${PR_NUMBER}. Write PHASE:escalate to escalate to a human reviewer."
|
||||
fi
|
||||
|
||||
# ── PHASE: escalate ──────────────────────────────────────────────────────
|
||||
elif [ "$phase" = "PHASE:escalate" ]; then
|
||||
status "escalated — waiting for human input on issue #${ISSUE}"
|
||||
ESCALATE_REASON=$(sed -n '2p' "$PHASE_FILE" 2>/dev/null | sed 's/^Reason: //' || echo "")
|
||||
log "phase: escalate — reason: ${ESCALATE_REASON:-none}"
|
||||
# Session stays alive — human input arrives via vault/forge
|
||||
|
||||
# ── PHASE: done ─────────────────────────────────────────────────────────────
|
||||
# PR merged and issue closed (by orchestrator or Claude). Just clean up local state.
|
||||
elif [ "$phase" = "PHASE:done" ]; then
|
||||
if [ -n "${PR_NUMBER:-}" ]; then
|
||||
status "phase done — PR #${PR_NUMBER} merged, cleaning up"
|
||||
else
|
||||
status "phase done — issue #${ISSUE} complete, cleaning up"
|
||||
fi
|
||||
|
||||
# Belt-and-suspenders: ensure in-progress label removed (idempotent)
|
||||
cleanup_labels
|
||||
|
||||
# Local cleanup
|
||||
agent_kill_session "$SESSION_NAME"
|
||||
cleanup_worktree
|
||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "${SCRATCH_FILE:-}" \
|
||||
"/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt"
|
||||
[ -n "${PR_NUMBER:-}" ] && rm -f "/tmp/review-injected-${PROJECT_NAME}-${PR_NUMBER}"
|
||||
CLAIMED=false # Don't unclaim again in cleanup()
|
||||
|
||||
# ── PHASE: failed ───────────────────────────────────────────────────────────
|
||||
elif [ "$phase" = "PHASE:failed" ]; then
|
||||
if [[ -f "$PHASE_FILE" ]]; then
|
||||
FAILURE_REASON=$(sed -n '2p' "$PHASE_FILE" | sed 's/^Reason: //')
|
||||
fi
|
||||
FAILURE_REASON="${FAILURE_REASON:-unspecified}"
|
||||
log "phase: failed — reason: ${FAILURE_REASON}"
|
||||
# Gitea labels API requires []int64 — look up the "backlog" label ID once
|
||||
BACKLOG_LABEL_ID=$(forge_api GET "/labels" 2>/dev/null \
|
||||
| jq -r '.[] | select(.name == "backlog") | .id' 2>/dev/null || true)
|
||||
BACKLOG_LABEL_ID="${BACKLOG_LABEL_ID:-1300815}"
|
||||
UNDERSPECIFIED_LABEL_ID=$(forge_api GET "/labels" 2>/dev/null \
|
||||
| jq -r '.[] | select(.name == "underspecified") | .id' 2>/dev/null || true)
|
||||
UNDERSPECIFIED_LABEL_ID="${UNDERSPECIFIED_LABEL_ID:-1300816}"
|
||||
|
||||
# Check if this is a refusal (Claude wrote refusal JSON to IMPL_SUMMARY_FILE)
|
||||
REFUSAL_JSON=""
|
||||
if [ -f "$IMPL_SUMMARY_FILE" ] && jq -e '.status' < "$IMPL_SUMMARY_FILE" >/dev/null 2>&1; then
|
||||
REFUSAL_JSON=$(cat "$IMPL_SUMMARY_FILE")
|
||||
fi
|
||||
|
||||
if [ -n "$REFUSAL_JSON" ] && [ "$FAILURE_REASON" = "refused" ]; then
|
||||
REFUSAL_STATUS=$(printf '%s' "$REFUSAL_JSON" | jq -r '.status')
|
||||
log "claude refused: ${REFUSAL_STATUS}"
|
||||
|
||||
# Write preflight result for dev-poll.sh
|
||||
printf '%s' "$REFUSAL_JSON" > "$PREFLIGHT_RESULT"
|
||||
|
||||
# Unclaim issue (restore backlog label, remove in-progress)
|
||||
cleanup_labels
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE}/labels" \
|
||||
-d "{\"labels\":[${BACKLOG_LABEL_ID}]}" >/dev/null 2>&1 || true
|
||||
|
||||
case "$REFUSAL_STATUS" in
|
||||
unmet_dependency)
|
||||
BLOCKED_BY_MSG=$(printf '%s' "$REFUSAL_JSON" | jq -r '.blocked_by // "unknown"')
|
||||
SUGGESTION=$(printf '%s' "$REFUSAL_JSON" | jq -r '.suggestion // empty')
|
||||
COMMENT_BODY="### Blocked by unmet dependency
|
||||
|
||||
${BLOCKED_BY_MSG}"
|
||||
if [ -n "$SUGGESTION" ] && [ "$SUGGESTION" != "null" ]; then
|
||||
COMMENT_BODY="${COMMENT_BODY}
|
||||
|
||||
**Suggestion:** Work on #${SUGGESTION} first."
|
||||
fi
|
||||
post_refusal_comment "🚧" "Unmet dependency" "$COMMENT_BODY"
|
||||
;;
|
||||
too_large)
|
||||
REASON=$(printf '%s' "$REFUSAL_JSON" | jq -r '.reason // "unspecified"')
|
||||
post_refusal_comment "📏" "Too large for single session" "### Why this can't be implemented as-is
|
||||
|
||||
${REASON}
|
||||
|
||||
### Next steps
|
||||
A maintainer should split this issue or add more detail to the spec."
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE}/labels" \
|
||||
-d "{\"labels\":[${UNDERSPECIFIED_LABEL_ID}]}" >/dev/null 2>&1 || true
|
||||
curl -sf -X DELETE \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${API}/issues/${ISSUE}/labels/${BACKLOG_LABEL_ID}" >/dev/null 2>&1 || true
|
||||
;;
|
||||
already_done)
|
||||
REASON=$(printf '%s' "$REFUSAL_JSON" | jq -r '.reason // "unspecified"')
|
||||
post_refusal_comment "✅" "Already implemented" "### Existing implementation
|
||||
|
||||
${REASON}
|
||||
|
||||
Closing as already implemented."
|
||||
curl -sf -X PATCH \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE}" \
|
||||
-d '{"state":"closed"}' >/dev/null 2>&1 || true
|
||||
;;
|
||||
*)
|
||||
post_refusal_comment "❓" "Unable to proceed" "The dev-agent could not process this issue.
|
||||
|
||||
Raw response:
|
||||
\`\`\`json
|
||||
$(printf '%s' "$REFUSAL_JSON" | head -c 2000)
|
||||
\`\`\`"
|
||||
;;
|
||||
esac
|
||||
|
||||
CLAIMED=false # Don't unclaim again in cleanup()
|
||||
agent_kill_session "$SESSION_NAME"
|
||||
cleanup_worktree
|
||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "${SCRATCH_FILE:-}" \
|
||||
"/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt"
|
||||
[ -n "${PR_NUMBER:-}" ] && rm -f "/tmp/review-injected-${PROJECT_NAME}-${PR_NUMBER}"
|
||||
return 1
|
||||
|
||||
else
|
||||
# Genuine unrecoverable failure — label blocked with diagnostic
|
||||
log "session failed: ${FAILURE_REASON}"
|
||||
post_blocked_diagnostic "$FAILURE_REASON"
|
||||
|
||||
agent_kill_session "$SESSION_NAME"
|
||||
if [ -n "${PR_NUMBER:-}" ]; then
|
||||
log "keeping worktree (PR #${PR_NUMBER} still open)"
|
||||
else
|
||||
cleanup_worktree
|
||||
fi
|
||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "${SCRATCH_FILE:-}" \
|
||||
"/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt"
|
||||
[ -n "${PR_NUMBER:-}" ] && rm -f "/tmp/review-injected-${PROJECT_NAME}-${PR_NUMBER}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── PHASE: crashed ──────────────────────────────────────────────────────────
|
||||
# Session died unexpectedly (OOM kill, tmux crash, etc.). Label blocked with
|
||||
# diagnostic comment so humans can triage directly on the issue.
|
||||
elif [ "$phase" = "PHASE:crashed" ]; then
|
||||
log "session crashed for issue #${ISSUE}"
|
||||
post_blocked_diagnostic "crashed"
|
||||
log "PRESERVED crashed worktree for debugging: $WORKTREE"
|
||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "${SCRATCH_FILE:-}" \
|
||||
"/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt"
|
||||
[ -n "${PR_NUMBER:-}" ] && rm -f "/tmp/review-injected-${PROJECT_NAME}-${PR_NUMBER}"
|
||||
|
||||
else
|
||||
log "WARNING: unknown phase value: ${phase}"
|
||||
fi
|
||||
}
|
||||
|
|
@ -8,13 +8,8 @@
|
|||
|
||||
set -euo pipefail
|
||||
|
||||
# Inline read_phase() function (previously from lib/agent-session.sh)
|
||||
# Read the current phase from a phase file, stripped of whitespace.
|
||||
# Usage: read_phase [file] — defaults to $PHASE_FILE
|
||||
read_phase() {
|
||||
local file="${1:-${PHASE_FILE:-}}"
|
||||
{ cat "$file" 2>/dev/null || true; } | head -1 | tr -d '[:space:]'
|
||||
}
|
||||
# Source canonical read_phase() from shared library
|
||||
source "$(dirname "$0")/../lib/agent-session.sh"
|
||||
|
||||
PROJECT="testproject"
|
||||
ISSUE="999"
|
||||
|
|
@ -89,7 +84,7 @@ else
|
|||
fail "PHASE:failed format: first='$first_line' second='$second_line'"
|
||||
fi
|
||||
|
||||
# ── Test 5: orchestrator read function (inline read_phase)
|
||||
# ── Test 5: orchestrator read function (canonical read_phase from lib/agent-session.sh)
|
||||
echo "PHASE:awaiting_ci" > "$PHASE_FILE"
|
||||
phase=$(read_phase "$PHASE_FILE")
|
||||
if [ "$phase" = "PHASE:awaiting_ci" ]; then
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
name: disinto-factory
|
||||
description: Set up and operate a disinto autonomous code factory.
|
||||
---
|
||||
|
||||
# Disinto Factory
|
||||
|
||||
You are helping the user set up and operate a **disinto autonomous code factory**.
|
||||
|
||||
## Guides
|
||||
|
||||
- **[Setup guide](setup.md)** — First-time factory setup: environment, init, verification, backlog seeding
|
||||
- **[Operations guide](operations.md)** — Day-to-day: status checks, CI debugging, unsticking issues, Forgejo access
|
||||
- **[Lessons learned](lessons-learned.md)** — Patterns for writing issues, debugging CI, retrying failures, vault operations, breaking down features
|
||||
|
||||
## Important context
|
||||
|
||||
- Read `AGENTS.md` for per-agent architecture and file-level docs
|
||||
- Read `VISION.md` for project philosophy
|
||||
- The factory uses a single internal Forgejo as its forge, regardless of where mirrors go
|
||||
- Dev-agent uses `claude -p` for one-shot implementation sessions
|
||||
- Mirror pushes happen automatically after every merge
|
||||
- Cron schedule: dev-poll every 5min, review-poll every 5min, gardener 4x/day
|
||||
|
||||
## References
|
||||
|
||||
- [Troubleshooting](references/troubleshooting.md)
|
||||
- [Factory status script](scripts/factory-status.sh)
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
# Working with the factory — lessons learned
|
||||
|
||||
## Writing issues for the dev agent
|
||||
|
||||
**Put everything in the issue body, not comments.** The dev agent reads the issue body when it starts work. It does not reliably read comments. If an issue fails and you need to add guidance for a retry, update the issue body.
|
||||
|
||||
**One approach per issue, no choices.** The dev agent cannot make design decisions. If there are multiple ways to solve a problem, decide before filing. Issues with "Option A or Option B" will confuse the agent.
|
||||
|
||||
**Issues must fit the templates.** Every backlog issue needs: affected files (max 3), acceptance criteria (max 5 checkboxes), and a clear proposed solution. If you cannot fill these fields, the issue is too big — label it `vision` and break it down first.
|
||||
|
||||
**Explicit dependencies prevent ordering bugs.** Add `Depends-on: #N` in the issue body. dev-poll checks these before pickup. Without explicit deps, the agent may attempt work on a stale codebase.
|
||||
|
||||
## Debugging CI failures
|
||||
|
||||
**Check CI logs via Woodpecker SQLite when the API fails.** The Woodpecker v3 log API may return HTML instead of JSON. Reliable fallback:
|
||||
```bash
|
||||
sqlite3 /var/lib/docker/volumes/disinto_woodpecker-data/_data/woodpecker.sqlite \
|
||||
"SELECT le.data FROM log_entries le \
|
||||
JOIN steps s ON le.step_id = s.id \
|
||||
JOIN workflows w ON s.pipeline_id = w.id \
|
||||
JOIN pipelines p ON w.pipeline_id = p.id \
|
||||
WHERE p.number = <N> AND s.name = '<step>' ORDER BY le.id"
|
||||
```
|
||||
|
||||
**When the agent fails repeatedly on CI, diagnose externally.** The dev agent cannot see CI log output (only pass/fail status). If the same step fails 3+ times, read the logs yourself and put the exact error and fix in the issue body.
|
||||
|
||||
## Retrying failed issues
|
||||
|
||||
**Clean up stale branches before retrying.** Old branches cause recovery mode which inherits stale code. Close the PR, delete the branch on Forgejo, then relabel to backlog.
|
||||
|
||||
**After a dependency lands, stale branches miss the fix.** If issue B depends on A, and B's PR was created before A merged, B's branch is stale. Close the PR and delete the branch so the agent starts fresh from current main.
|
||||
|
||||
## Environment gotchas
|
||||
|
||||
**Alpine/BusyBox differs from Debian.** CI and edge containers use Alpine:
|
||||
- `grep -P` (Perl regex) does not work — use `grep -E`
|
||||
- `USER` variable is unset — set it explicitly: `USER=$(whoami); export USER`
|
||||
- Network calls fail during `docker build` in LXD — download binaries on the host, COPY into images
|
||||
|
||||
**The host repo drifts from Forgejo main.** If factory code is bind-mounted, the host checkout goes stale. Pull regularly or use versioned releases.
|
||||
|
||||
## Vault operations
|
||||
|
||||
**The human merging a vault PR must be a Forgejo site admin.** The dispatcher verifies `is_admin` on the merger. Promote your user via the Forgejo CLI or database if needed.
|
||||
|
||||
**Result files cache failures.** If a vault action fails, the dispatcher writes `.result.json` and skips it. To retry: delete the result file inside the edge container.
|
||||
|
||||
## Breaking down large features
|
||||
|
||||
**Vision issues need structured decomposition.** When a feature touches multiple subsystems or has design forks, label it `vision`. Break it down by identifying what exists, what can be reused, where the design forks are, and resolve them before filing backlog issues.
|
||||
|
||||
**Prefer gluecode over greenfield.** Check if Forgejo API, Woodpecker, Docker, or existing lib/ functions can do the job before building new components.
|
||||
|
||||
**Max 7 sub-issues per sprint.** If a breakdown produces more, split into two sprints.
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
# Ongoing operations
|
||||
|
||||
### Check factory status
|
||||
|
||||
```bash
|
||||
source .env
|
||||
|
||||
# Issues
|
||||
curl -sf "http://localhost:3000/api/v1/repos/<org>/<repo>/issues?state=open" \
|
||||
-H "Authorization: token $FORGE_TOKEN" \
|
||||
| jq -r '.[] | "#\(.number) [\(.labels | map(.name) | join(","))] \(.title)"'
|
||||
|
||||
# PRs
|
||||
curl -sf "http://localhost:3000/api/v1/repos/<org>/<repo>/pulls?state=open" \
|
||||
-H "Authorization: token $FORGE_TOKEN" \
|
||||
| jq -r '.[] | "PR #\(.number) [\(.head.ref)] \(.title)"'
|
||||
|
||||
# Agent logs
|
||||
docker exec disinto-agents-1 tail -20 /home/agent/data/logs/dev/dev-agent.log
|
||||
```
|
||||
|
||||
### Check CI
|
||||
|
||||
```bash
|
||||
source .env
|
||||
WP_CSRF=$(curl -sf -b "user_sess=$WOODPECKER_TOKEN" http://localhost:8000/web-config.js \
|
||||
| sed -n 's/.*WOODPECKER_CSRF = "\([^"]*\)".*/\1/p')
|
||||
curl -sf -b "user_sess=$WOODPECKER_TOKEN" -H "X-CSRF-Token: $WP_CSRF" \
|
||||
"http://localhost:8000/api/repos/1/pipelines?page=1&per_page=5" \
|
||||
| jq '.[] | {number, status, event}'
|
||||
```
|
||||
|
||||
### Unstick a blocked issue
|
||||
|
||||
When a dev-agent run fails (CI timeout, implementation error), the issue gets labeled `blocked`:
|
||||
|
||||
1. Close stale PR and delete the branch
|
||||
2. `docker exec disinto-agents-1 rm -f /tmp/dev-agent-*.json /tmp/dev-agent-*.lock`
|
||||
3. Relabel the issue to `backlog`
|
||||
4. Update agent repo: `docker exec -u agent disinto-agents-1 bash -c "cd /home/agent/repos/<name> && git fetch origin && git reset --hard origin/main"`
|
||||
|
||||
### Access Forgejo UI
|
||||
|
||||
If running in an LXD container with reverse tunnel:
|
||||
```bash
|
||||
# From your machine:
|
||||
ssh -L 3000:localhost:13000 user@jump-host
|
||||
# Open http://localhost:3000
|
||||
```
|
||||
|
||||
Reset admin password if needed:
|
||||
```bash
|
||||
docker exec disinto-forgejo-1 su -c "forgejo admin user change-password --username disinto-admin --password <new-pw> --must-change-password=false" git
|
||||
```
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
# Troubleshooting
|
||||
|
||||
## WOODPECKER_TOKEN empty after init
|
||||
|
||||
The OAuth2 flow failed. Common causes:
|
||||
|
||||
1. **URL-encoded redirect_uri mismatch**: Forgejo logs show "Unregistered Redirect URI".
|
||||
The init script must rewrite both plain and URL-encoded Docker hostnames.
|
||||
|
||||
2. **Forgejo must_change_password**: Admin user was created with forced password change.
|
||||
The init script calls `--must-change-password=false` but Forgejo 11.x sometimes ignores it.
|
||||
|
||||
3. **WOODPECKER_OPEN not set**: WP refuses first-user OAuth registration without it.
|
||||
|
||||
Manual fix: reset admin password and re-run the token generation manually, or
|
||||
use the Woodpecker UI to create a token.
|
||||
|
||||
## WP CI agent won't connect (DeadlineExceeded)
|
||||
|
||||
gRPC over Docker bridge fails in LXD (and possibly other nested container environments).
|
||||
The compose template uses `network_mode: host` + `privileged: true` for the agent.
|
||||
If you see this error, check:
|
||||
- Server exposes port 9000: `grep "9000:9000" docker-compose.yml`
|
||||
- Agent uses `localhost:9000`: `grep "WOODPECKER_SERVER" docker-compose.yml`
|
||||
- Agent has `network_mode: host`
|
||||
|
||||
## CI clone fails (could not resolve host)
|
||||
|
||||
CI containers need to resolve Docker service names (e.g., `forgejo`).
|
||||
Check `WOODPECKER_BACKEND_DOCKER_NETWORK` is set on the agent.
|
||||
|
||||
## Webhooks not delivered
|
||||
|
||||
Forgejo blocks outgoing webhooks by default. Check:
|
||||
```bash
|
||||
docker logs disinto-forgejo-1 2>&1 | grep "webhook.*ALLOWED_HOST_LIST"
|
||||
```
|
||||
Fix: add `FORGEJO__webhook__ALLOWED_HOST_LIST: "private"` to Forgejo environment.
|
||||
|
||||
Also verify the webhook exists:
|
||||
```bash
|
||||
curl -sf -u "disinto-admin:<password>" "http://localhost:3000/api/v1/repos/<org>/<repo>/hooks" | jq '.[].config.url'
|
||||
```
|
||||
If missing, deactivate and reactivate the repo in Woodpecker to auto-create it.
|
||||
|
||||
## Dev-agent fails with "cd: no such file or directory"
|
||||
|
||||
`PROJECT_REPO_ROOT` inside the agents container points to a host path that doesn't
|
||||
exist in the container. Check the compose env:
|
||||
```bash
|
||||
docker inspect disinto-agents-1 --format '{{range .Config.Env}}{{println .}}{{end}}' | grep PROJECT_REPO_ROOT
|
||||
```
|
||||
Should be `/home/agent/repos/<name>`, not `/home/<user>/<name>`.
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# factory-status.sh — Quick status check for a running disinto factory
|
||||
set -euo pipefail
|
||||
|
||||
FACTORY_ROOT="${1:-$(cd "$(dirname "$0")/../.." && pwd)}"
|
||||
source "${FACTORY_ROOT}/.env" 2>/dev/null || { echo "No .env found at ${FACTORY_ROOT}"; exit 1; }
|
||||
|
||||
FORGE_URL="${FORGE_URL:-http://localhost:3000}"
|
||||
REPO=$(grep '^repo ' "${FACTORY_ROOT}/projects/"*.toml 2>/dev/null | head -1 | sed 's/.*= *"//;s/"//')
|
||||
[ -z "$REPO" ] && { echo "No project TOML found"; exit 1; }
|
||||
|
||||
echo "=== Stack ==="
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}" 2>/dev/null | grep disinto
|
||||
|
||||
echo ""
|
||||
echo "=== Open Issues ==="
|
||||
curl -sf "${FORGE_URL}/api/v1/repos/${REPO}/issues?state=open&limit=20" \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
| jq -r '.[] | "#\(.number) [\(.labels | map(.name) | join(","))] \(.title)"' 2>/dev/null || echo "(API error)"
|
||||
|
||||
echo ""
|
||||
echo "=== Open PRs ==="
|
||||
curl -sf "${FORGE_URL}/api/v1/repos/${REPO}/pulls?state=open&limit=10" \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
| jq -r '.[] | "PR #\(.number) [\(.head.ref)] \(.title)"' 2>/dev/null || echo "none"
|
||||
|
||||
echo ""
|
||||
echo "=== Agent Activity ==="
|
||||
docker exec disinto-agents-1 bash -c "tail -5 /home/agent/data/logs/dev/dev-agent.log 2>/dev/null" || echo "(no logs)"
|
||||
|
||||
echo ""
|
||||
echo "=== Claude Running? ==="
|
||||
docker exec disinto-agents-1 bash -c "
|
||||
found=false
|
||||
for f in /proc/[0-9]*/cmdline; do
|
||||
cmd=\$(tr '\0' ' ' < \"\$f\" 2>/dev/null)
|
||||
if echo \"\$cmd\" | grep -q 'claude.*-p'; then found=true; echo 'Yes — Claude is actively working'; break; fi
|
||||
done
|
||||
\$found || echo 'No — idle'
|
||||
" 2>/dev/null
|
||||
|
||||
echo ""
|
||||
echo "=== Mirrors ==="
|
||||
cd "${FACTORY_ROOT}" 2>/dev/null && git remote -v | grep -E 'github|codeberg' | grep push || echo "none configured"
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
# First-time setup
|
||||
|
||||
Walk the user through these steps interactively. Ask questions where marked with [ASK].
|
||||
|
||||
### 1. Environment
|
||||
|
||||
[ASK] Where will the factory run? Options:
|
||||
- **LXD container** (recommended for isolation) — need Debian 12, Docker, nesting enabled
|
||||
- **Bare VM or server** — need Debian/Ubuntu with Docker
|
||||
- **Existing container** — check prerequisites
|
||||
|
||||
Verify prerequisites:
|
||||
```bash
|
||||
docker --version && git --version && jq --version && curl --version && tmux -V && python3 --version && claude --version
|
||||
```
|
||||
|
||||
Any missing tool — help the user install it before continuing.
|
||||
|
||||
### 2. Clone disinto and choose a target project
|
||||
|
||||
Clone the disinto factory itself:
|
||||
```bash
|
||||
git clone https://codeberg.org/johba/disinto.git && cd disinto
|
||||
```
|
||||
|
||||
[ASK] What repository should the factory develop? Provide the **remote repository URL** in one of these formats:
|
||||
- Full URL: `https://github.com/johba/harb.git` or `https://codeberg.org/johba/harb.git`
|
||||
- Short slug: `johba/harb` (uses local Forgejo as the primary remote)
|
||||
|
||||
The factory will clone from the remote URL (if provided) or from your local Forgejo, then mirror to the remote.
|
||||
|
||||
Then initialize the factory for that project:
|
||||
```bash
|
||||
bin/disinto init johba/harb --yes
|
||||
# or with full URL:
|
||||
bin/disinto init https://github.com/johba/harb.git --yes
|
||||
```
|
||||
|
||||
The `init` command will:
|
||||
- Create all bot users (dev-bot, review-bot, etc.) on the local Forgejo
|
||||
- Generate and save `WOODPECKER_TOKEN`
|
||||
- Start the stack containers
|
||||
- Clone the target repo into the agent workspace
|
||||
|
||||
> **Note:** The `--repo-root` flag is optional and only needed if you want to customize
|
||||
> where the cloned repo lives. By default, it goes under `/home/agent/repos/<name>`.
|
||||
|
||||
### 3. Post-init verification
|
||||
|
||||
Run this checklist — fix any failures before proceeding:
|
||||
|
||||
```bash
|
||||
# Stack healthy?
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}"
|
||||
# Expected: forgejo, woodpecker (healthy), woodpecker-agent (healthy), agents, edge, staging
|
||||
|
||||
# Token generated?
|
||||
grep WOODPECKER_TOKEN .env | grep -v "^$" && echo "OK" || echo "MISSING — see references/troubleshooting.md"
|
||||
|
||||
# Agent cron active?
|
||||
docker exec -u agent disinto-agents-1 crontab -l -u agent
|
||||
|
||||
# Agent can reach Forgejo?
|
||||
docker exec disinto-agents-1 bash -c "source /home/agent/disinto/.env && curl -sf http://forgejo:3000/api/v1/version | jq .version"
|
||||
|
||||
# Agent repo cloned?
|
||||
docker exec -u agent disinto-agents-1 ls /home/agent/repos/
|
||||
```
|
||||
|
||||
If the agent repo is missing, clone it:
|
||||
```bash
|
||||
docker exec disinto-agents-1 chown -R agent:agent /home/agent/repos
|
||||
docker exec -u agent disinto-agents-1 bash -c "source /home/agent/disinto/.env && git clone http://dev-bot:\${FORGE_TOKEN}@forgejo:3000/<org>/<repo>.git /home/agent/repos/<name>"
|
||||
```
|
||||
|
||||
### 4. Create the project configuration file
|
||||
|
||||
The factory uses a TOML file to configure how it manages your project. Create
|
||||
`projects/<name>.toml` based on the template format:
|
||||
|
||||
```toml
|
||||
# projects/harb.toml
|
||||
|
||||
name = "harb"
|
||||
repo = "johba/harb"
|
||||
forge_url = "http://localhost:3000"
|
||||
repo_root = "/home/agent/repos/harb"
|
||||
primary_branch = "master"
|
||||
|
||||
[ci]
|
||||
woodpecker_repo_id = 0
|
||||
stale_minutes = 60
|
||||
|
||||
[services]
|
||||
containers = ["ponder"]
|
||||
|
||||
[monitoring]
|
||||
check_prs = true
|
||||
check_dev_agent = true
|
||||
check_pipeline_stall = true
|
||||
|
||||
# [mirrors]
|
||||
# github = "git@github.com:johba/harb.git"
|
||||
# codeberg = "git@codeberg.org:johba/harb.git"
|
||||
```
|
||||
|
||||
**Key fields:**
|
||||
- `name`: Project identifier (used for file names, logs, etc.)
|
||||
- `repo`: The source repo in `owner/name` format
|
||||
- `forge_url`: URL of your local Forgejo instance
|
||||
- `repo_root`: Where the agent clones the repo
|
||||
- `primary_branch`: Default branch name (e.g., `main` or `master`)
|
||||
- `woodpecker_repo_id`: Set to `0` initially; auto-populated on first CI run
|
||||
- `containers`: List of Docker containers the factory should manage
|
||||
- `mirrors`: Optional external forge URLs for backup/sync
|
||||
|
||||
### 5. Mirrors (optional)
|
||||
|
||||
[ASK] Should the factory mirror to external forges? If yes, which?
|
||||
- GitHub: need repo URL and SSH key added to GitHub account
|
||||
- Codeberg: need repo URL and SSH key added to Codeberg account
|
||||
|
||||
Show the user their public key:
|
||||
```bash
|
||||
cat ~/.ssh/id_ed25519.pub
|
||||
```
|
||||
|
||||
Test SSH access:
|
||||
```bash
|
||||
ssh -T git@github.com 2>&1; ssh -T git@codeberg.org 2>&1
|
||||
```
|
||||
|
||||
If SSH host keys are missing: `ssh-keyscan github.com codeberg.org >> ~/.ssh/known_hosts 2>/dev/null`
|
||||
|
||||
Edit `projects/<name>.toml` to uncomment and configure mirrors:
|
||||
```toml
|
||||
[mirrors]
|
||||
github = "git@github.com:Org/repo.git"
|
||||
codeberg = "git@codeberg.org:user/repo.git"
|
||||
```
|
||||
|
||||
Test with a manual push:
|
||||
```bash
|
||||
source .env && source lib/env.sh && export PROJECT_TOML=projects/<name>.toml && source lib/load-project.sh && source lib/mirrors.sh && mirror_push
|
||||
```
|
||||
|
||||
### 6. Seed the backlog
|
||||
|
||||
[ASK] What should the factory work on first? Brainstorm with the user.
|
||||
|
||||
Help them create issues on the local Forgejo. Each issue needs:
|
||||
- A clear title prefixed with `fix:`, `feat:`, or `chore:`
|
||||
- A body describing what to change, which files, and any constraints
|
||||
- The `backlog` label (so the dev-agent picks it up)
|
||||
|
||||
```bash
|
||||
source .env
|
||||
BACKLOG_ID=$(curl -sf "http://localhost:3000/api/v1/repos/<org>/<repo>/labels" \
|
||||
-H "Authorization: token $FORGE_TOKEN" | jq -r '.[] | select(.name=="backlog") | .id')
|
||||
|
||||
curl -sf -X POST "http://localhost:3000/api/v1/repos/<org>/<repo>/issues" \
|
||||
-H "Authorization: token $FORGE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"title\": \"<title>\", \"body\": \"<body>\", \"labels\": [$BACKLOG_ID]}"
|
||||
```
|
||||
|
||||
For issues with dependencies, add `Depends-on: #N` in the body — the dev-agent checks
|
||||
these before starting.
|
||||
|
||||
Use labels:
|
||||
- `backlog` — ready for the dev-agent
|
||||
- `blocked` — parked, not for the factory
|
||||
- No label — tracked but not for autonomous work
|
||||
|
||||
### 7. Watch it work
|
||||
|
||||
The dev-agent polls every 5 minutes. Trigger manually to see it immediately:
|
||||
```bash
|
||||
source .env
|
||||
export PROJECT_TOML=projects/<name>.toml
|
||||
docker exec -u agent disinto-agents-1 bash -c "cd /home/agent/disinto && bash dev/dev-poll.sh projects/<name>.toml"
|
||||
```
|
||||
|
||||
Then monitor:
|
||||
```bash
|
||||
# Watch the agent work
|
||||
docker exec disinto-agents-1 tail -f /home/agent/data/logs/dev/dev-agent.log
|
||||
|
||||
# Check for Claude running
|
||||
docker exec disinto-agents-1 bash -c "for f in /proc/[0-9]*/cmdline; do cmd=\$(tr '\0' ' ' < \$f 2>/dev/null); echo \$cmd | grep -q 'claude.*-p' && echo 'Claude is running'; done"
|
||||
```
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
version: "3.8"
|
||||
|
||||
services:
|
||||
agents:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/agents/Dockerfile
|
||||
image: disinto/agents:latest
|
||||
container_name: disinto-agents
|
||||
volumes:
|
||||
- ./data/agents:/home/agent/data
|
||||
- ./disinto:/home/agent/disinto:ro
|
||||
- /usr/local/bin/claude:/usr/local/bin/claude:ro
|
||||
environment:
|
||||
- DISINTO_AGENTS=review,gardener
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- FORGE_TOKEN=${FORGE_TOKEN:-}
|
||||
- FORGE_URL=http://forgejo:3000
|
||||
depends_on:
|
||||
- forgejo
|
||||
|
||||
agents-llama:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/agents/Dockerfile
|
||||
image: disinto/agents-llama:latest
|
||||
container_name: disinto-agents-llama
|
||||
volumes:
|
||||
- ./data/llama:/home/agent/data
|
||||
- ./disinto:/home/agent/disinto:ro
|
||||
- /usr/local/bin/claude:/usr/local/bin/claude:ro
|
||||
environment:
|
||||
- DISINTO_AGENTS=dev
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- FORGE_TOKEN=${FORGE_TOKEN:-}
|
||||
- FORGE_URL=http://forgejo:3000
|
||||
- PROJECT_TOML=projects/disinto.toml
|
||||
- FORGE_REPO=${FORGE_REPO:-disinto-admin/disinto}
|
||||
depends_on:
|
||||
- forgejo
|
||||
|
||||
reproduce:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/reproduce/Dockerfile
|
||||
image: disinto-reproduce:latest
|
||||
network_mode: host
|
||||
profiles: ["reproduce"]
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- agent-data:/home/agent/data
|
||||
- project-repos:/home/agent/repos
|
||||
- ${HOME}/.claude:/home/agent/.claude
|
||||
- /usr/local/bin/claude:/usr/local/bin/claude:ro
|
||||
- ${HOME}/.ssh:/home/agent/.ssh:ro
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
edge:
|
||||
build:
|
||||
context: docker/edge
|
||||
dockerfile: Dockerfile
|
||||
image: disinto/edge:latest
|
||||
container_name: disinto-edge
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /usr/local/bin/claude:/usr/local/bin/claude:ro
|
||||
- ${HOME}/.claude.json:/root/.claude.json:ro
|
||||
- ${HOME}/.claude:/root/.claude:ro
|
||||
- disinto-logs:/opt/disinto-logs
|
||||
environment:
|
||||
- FORGE_SUPERVISOR_TOKEN=${FORGE_SUPERVISOR_TOKEN:-}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- CLAUDE_MODEL=claude-sonnet-4-6
|
||||
- FORGE_TOKEN=${FORGE_TOKEN:-}
|
||||
- FORGE_URL=http://forgejo:3000
|
||||
- DISINTO_CONTAINER=1
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
depends_on:
|
||||
- forgejo
|
||||
|
||||
forgejo:
|
||||
image: codeberg.org/forgejo/forgejo:1
|
||||
container_name: disinto-forgejo
|
||||
volumes:
|
||||
- ./data/forgejo:/var/lib/forgejo
|
||||
environment:
|
||||
- FORGEJO__database__DB_TYPE=sqlite3
|
||||
- FORGEJO__service__REGISTER_EMAIL_CONFIRMATION=false
|
||||
- FORGEJO__service__ENABLE_NOTIFY_MAIL=false
|
||||
- FORGEJO__service__DISABLE_REGISTRATION=true
|
||||
- FORGEJO__service__REQUIRE_SIGNIN_VIEW=true
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
volumes:
|
||||
disinto-logs:
|
||||
|
|
@ -1,18 +1,14 @@
|
|||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
bash curl git jq tmux cron python3 python3-pip openssh-client ca-certificates age shellcheck procps \
|
||||
&& pip3 install --break-system-packages networkx \
|
||||
bash curl git jq tmux cron python3 openssh-client ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Pre-built binaries (copied from docker/agents/bin/)
|
||||
# SOPS — encrypted data decryption tool
|
||||
COPY docker/agents/bin/sops /usr/local/bin/sops
|
||||
RUN chmod +x /usr/local/bin/sops
|
||||
|
||||
# tea CLI — official Gitea/Forgejo CLI for issue/label/comment operations
|
||||
COPY docker/agents/bin/tea /usr/local/bin/tea
|
||||
RUN chmod +x /usr/local/bin/tea
|
||||
# Checksum from https://dl.gitea.com/tea/0.9.2/tea-0.9.2-linux-amd64.sha256
|
||||
RUN curl -sL https://dl.gitea.com/tea/0.9.2/tea-0.9.2-linux-amd64 -o /usr/local/bin/tea \
|
||||
&& echo "be10cdf9a619e3c0f121df874960ed19b53e62d1c7036cf60313a28b5227d54d /usr/local/bin/tea" | sha256sum -c - \
|
||||
&& chmod +x /usr/local/bin/tea
|
||||
|
||||
# Claude CLI is mounted from the host via docker-compose volume.
|
||||
# No internet access to cli.anthropic.com required at build time.
|
||||
|
|
@ -20,14 +16,11 @@ RUN chmod +x /usr/local/bin/tea
|
|||
# Non-root user
|
||||
RUN useradd -m -u 1000 -s /bin/bash agent
|
||||
|
||||
# Copy disinto code into the image
|
||||
COPY . /home/agent/disinto
|
||||
|
||||
COPY docker/agents/entrypoint.sh /entrypoint.sh
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Entrypoint runs as root to start the cron daemon;
|
||||
# cron jobs execute as the agent user (crontab -u agent).
|
||||
WORKDIR /home/agent/disinto
|
||||
WORKDIR /home/agent
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
LOG_DIR="/home/agent/data/logs/dev"
|
||||
mkdir -p "$LOG_DIR" /home/agent/data
|
||||
chown -R agent:agent /home/agent/data 2>/dev/null || true
|
||||
|
||||
log() {
|
||||
printf "[%s] llama-loop: %s\n" "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" | tee -a "$LOG_DIR/llama-loop.log"
|
||||
}
|
||||
|
||||
# Apply token override for named agent identity
|
||||
if [ -n "${FORGE_TOKEN_OVERRIDE:-}" ]; then
|
||||
export FORGE_TOKEN="$FORGE_TOKEN_OVERRIDE"
|
||||
fi
|
||||
|
||||
log "Starting llama dev-agent loop"
|
||||
log "Backend: ${ANTHROPIC_BASE_URL:-not set}"
|
||||
log "Claude CLI: $(claude --version 2>&1 || echo not found)"
|
||||
log "Agent identity: $(curl -sf -H "Authorization: token ${FORGE_TOKEN}" "${FORGE_URL:-http://forgejo:3000}/api/v1/user" 2>/dev/null | jq -r '.login // "unknown"')"
|
||||
|
||||
# Clone repo if not present
|
||||
if [ ! -d "${PROJECT_REPO_ROOT}/.git" ]; then
|
||||
log "Cloning repo..."
|
||||
mkdir -p "$(dirname "$PROJECT_REPO_ROOT")"
|
||||
chown -R agent:agent /home/agent/repos 2>/dev/null || true
|
||||
# Use password auth for git HTTP — Forgejo 11.x rejects API tokens for push (#361)
|
||||
su -s /bin/bash agent -c "git clone http://dev-bot:${FORGE_PASS:-${FORGE_TOKEN}}@forgejo:3000/${FORGE_REPO:-disinto-admin/disinto}.git ${PROJECT_REPO_ROOT}"
|
||||
log "Repo cloned"
|
||||
fi
|
||||
|
||||
# Reset base repo to origin/main to avoid divergence warnings
|
||||
su -s /bin/bash agent -c "
|
||||
cd \"${PROJECT_REPO_ROOT}\"
|
||||
git fetch origin main
|
||||
git checkout main 2>/dev/null || true
|
||||
git reset --hard origin/main
|
||||
" || true
|
||||
log "Base repo reset to origin/main"
|
||||
|
||||
log "Entering poll loop (interval: ${POLL_INTERVAL:-300}s)"
|
||||
|
||||
while true; do
|
||||
# Clear stale session IDs before each poll.
|
||||
# Local llama does not support --resume (no server-side session storage).
|
||||
# Stale .sid files cause agent_run to exit instantly on every retry.
|
||||
rm -f /tmp/dev-session-*.sid 2>/dev/null || true
|
||||
|
||||
su -s /bin/bash agent -c "
|
||||
export FORGE_TOKEN='${FORGE_TOKEN}'
|
||||
export FORGE_TOKEN_OVERRIDE='${FORGE_TOKEN_OVERRIDE:-}'
|
||||
export ANTHROPIC_API_KEY='${ANTHROPIC_API_KEY:-}'
|
||||
export ANTHROPIC_BASE_URL='${ANTHROPIC_BASE_URL:-}'
|
||||
export CLAUDE_CONFIG_DIR='${CLAUDE_CONFIG_DIR:-}'
|
||||
cd /home/agent/disinto && \
|
||||
bash dev/dev-poll.sh ${PROJECT_TOML:-projects/disinto.toml}
|
||||
" >> "$LOG_DIR/llama-loop.log" 2>&1 || true
|
||||
sleep "${POLL_INTERVAL:-300}"
|
||||
done
|
||||
|
|
@ -18,20 +18,7 @@ log() {
|
|||
|
||||
# Build crontab from project TOMLs and install for the agent user.
|
||||
install_project_crons() {
|
||||
local cron_lines="PATH=/usr/local/bin:/usr/bin:/bin
|
||||
DISINTO_CONTAINER=1
|
||||
USER=agent
|
||||
FORGE_URL=http://forgejo:3000"
|
||||
|
||||
# Parse DISINTO_AGENTS env var (default: all agents)
|
||||
# Expected format: comma-separated list like "review,gardener" or "dev"
|
||||
# Note: supervisor is NOT installed here — it runs on the host, not in container.
|
||||
# Supervisor requires host-level Docker access and pgrep, which the container lacks.
|
||||
local agents_to_run="review,dev,gardener"
|
||||
if [ -n "${DISINTO_AGENTS:-}" ]; then
|
||||
agents_to_run="$DISINTO_AGENTS"
|
||||
fi
|
||||
|
||||
local cron_lines=""
|
||||
for toml in "${DISINTO_DIR}"/projects/*.toml; do
|
||||
[ -f "$toml" ] || continue
|
||||
local pname
|
||||
|
|
@ -42,31 +29,15 @@ with open(sys.argv[1], 'rb') as f:
|
|||
" "$toml" 2>/dev/null) || continue
|
||||
|
||||
cron_lines="${cron_lines}
|
||||
PROJECT_REPO_ROOT=/home/agent/repos/${pname}
|
||||
# disinto: ${pname}"
|
||||
|
||||
# Add review-poll only if review agent is configured
|
||||
if echo "$agents_to_run" | grep -qw "review"; then
|
||||
cron_lines="${cron_lines}
|
||||
2,7,12,17,22,27,32,37,42,47,52,57 * * * * ${DISINTO_DIR}/review/review-poll.sh ${toml} >>/home/agent/data/logs/cron.log 2>&1"
|
||||
fi
|
||||
|
||||
# Add dev-poll only if dev agent is configured
|
||||
if echo "$agents_to_run" | grep -qw "dev"; then
|
||||
cron_lines="${cron_lines}
|
||||
4,9,14,19,24,29,34,39,44,49,54,59 * * * * ${DISINTO_DIR}/dev/dev-poll.sh ${toml} >>/home/agent/data/logs/cron.log 2>&1"
|
||||
fi
|
||||
|
||||
# Add gardener-run only if gardener agent is configured
|
||||
if echo "$agents_to_run" | grep -qw "gardener"; then
|
||||
cron_lines="${cron_lines}
|
||||
0 0,6,12,18 * * * cd ${DISINTO_DIR} && bash gardener/gardener-run.sh ${toml} >>/home/agent/data/logs/cron.log 2>&1"
|
||||
fi
|
||||
# disinto: ${pname}
|
||||
2,7,12,17,22,27,32,37,42,47,52,57 * * * * ${DISINTO_DIR}/review/review-poll.sh ${toml} >/dev/null 2>&1
|
||||
4,9,14,19,24,29,34,39,44,49,54,59 * * * * ${DISINTO_DIR}/dev/dev-poll.sh ${toml} >/dev/null 2>&1
|
||||
0 0,6,12,18 * * * cd ${DISINTO_DIR} && bash gardener/gardener-run.sh ${toml} >/dev/null 2>&1"
|
||||
done
|
||||
|
||||
if [ -n "$cron_lines" ]; then
|
||||
printf '%s\n' "$cron_lines" | crontab -u agent -
|
||||
log "Installed crontab for agent user (agents: ${agents_to_run})"
|
||||
log "Installed crontab for agent user"
|
||||
else
|
||||
log "No project TOMLs found — crontab empty"
|
||||
fi
|
||||
|
|
@ -74,9 +45,6 @@ PROJECT_REPO_ROOT=/home/agent/repos/${pname}
|
|||
|
||||
log "Agent container starting"
|
||||
|
||||
# Set USER for scripts that source lib/env.sh (e.g., OPS_REPO_ROOT default)
|
||||
export USER=agent
|
||||
|
||||
# Verify Claude CLI is available (expected via volume mount from host).
|
||||
if ! command -v claude &>/dev/null; then
|
||||
log "FATAL: claude CLI not found in PATH."
|
||||
|
|
@ -101,38 +69,6 @@ fi
|
|||
|
||||
install_project_crons
|
||||
|
||||
# Configure git credential helper for password-based HTTP auth.
|
||||
# Forgejo 11.x rejects API tokens for git push (#361); password auth works.
|
||||
# This ensures all git operations (clone, fetch, push) from worktrees use
|
||||
# password auth without needing tokens embedded in remote URLs.
|
||||
if [ -n "${FORGE_PASS:-}" ] && [ -n "${FORGE_URL:-}" ]; then
|
||||
_forge_host=$(printf '%s' "$FORGE_URL" | sed 's|https\?://||; s|/.*||')
|
||||
_forge_proto=$(printf '%s' "$FORGE_URL" | sed 's|://.*||')
|
||||
# Determine the bot username from FORGE_TOKEN identity (or default to dev-bot)
|
||||
_bot_user=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_URL}/api/v1/user" 2>/dev/null | jq -r '.login // empty') || _bot_user=""
|
||||
_bot_user="${_bot_user:-dev-bot}"
|
||||
|
||||
# Write a static credential helper script (git credential protocol)
|
||||
cat > /home/agent/.git-credentials-helper <<CREDEOF
|
||||
#!/bin/sh
|
||||
# Auto-generated git credential helper for Forgejo password auth (#361)
|
||||
# Only respond to "get" action; ignore "store" and "erase".
|
||||
[ "\$1" = "get" ] || exit 0
|
||||
# Read and discard stdin (git sends protocol/host info)
|
||||
cat >/dev/null
|
||||
echo "protocol=${_forge_proto}"
|
||||
echo "host=${_forge_host}"
|
||||
echo "username=${_bot_user}"
|
||||
echo "password=${FORGE_PASS}"
|
||||
CREDEOF
|
||||
chmod 755 /home/agent/.git-credentials-helper
|
||||
chown agent:agent /home/agent/.git-credentials-helper
|
||||
|
||||
su -s /bin/bash agent -c "git config --global credential.helper '/home/agent/.git-credentials-helper'"
|
||||
log "Git credential helper configured for ${_bot_user}@${_forge_host} (password auth)"
|
||||
fi
|
||||
|
||||
# Configure tea CLI login for forge operations (runs as agent user).
|
||||
# tea stores config in ~/.config/tea/ — persistent across container restarts
|
||||
# only if that directory is on a mounted volume.
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
FROM caddy:latest
|
||||
RUN apt-get update && apt-get install -y bash jq curl git docker.io && rm -rf /var/lib/apt/lists/*
|
||||
COPY entrypoint-edge.sh /usr/local/bin/entrypoint-edge.sh
|
||||
ENTRYPOINT ["bash", "/usr/local/bin/entrypoint-edge.sh"]
|
||||
|
|
@ -1,787 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# dispatcher.sh — Edge task dispatcher
|
||||
#
|
||||
# Polls the ops repo for vault actions that arrived via admin-merged PRs.
|
||||
#
|
||||
# Flow:
|
||||
# 1. Poll loop: git pull the ops repo every 60s
|
||||
# 2. Scan vault/actions/ for TOML files without .result.json
|
||||
# 3. Verify TOML arrived via merged PR with admin merger (Forgejo API)
|
||||
# 4. Validate TOML using vault-env.sh validator
|
||||
# 5. Decrypt .env.vault.enc and extract only declared secrets
|
||||
# 6. Launch: docker run --rm disinto-agents:latest <formula> <action-id>
|
||||
# 7. Write <action-id>.result.json with exit code, timestamp, logs summary
|
||||
#
|
||||
# Part of #76.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Resolve script root (parent of lib/)
|
||||
SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
# Source shared environment
|
||||
source "${SCRIPT_ROOT}/../lib/env.sh"
|
||||
|
||||
# Load vault secrets after env.sh (env.sh unsets them for agent security)
|
||||
# Vault secrets must be available to the dispatcher
|
||||
if [ -f "$FACTORY_ROOT/.env.vault.enc" ] && command -v sops &>/dev/null; then
|
||||
set -a
|
||||
eval "$(sops -d --output-type dotenv "$FACTORY_ROOT/.env.vault.enc" 2>/dev/null)" \
|
||||
|| echo "Warning: failed to decrypt .env.vault.enc — vault secrets not loaded" >&2
|
||||
set +a
|
||||
elif [ -f "$FACTORY_ROOT/.env.vault" ]; then
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
source "$FACTORY_ROOT/.env.vault"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Ops repo location (vault/actions directory)
|
||||
OPS_REPO_ROOT="${OPS_REPO_ROOT:-/home/debian/disinto-ops}"
|
||||
VAULT_ACTIONS_DIR="${OPS_REPO_ROOT}/vault/actions"
|
||||
|
||||
# Vault action validation
|
||||
VAULT_ENV="${SCRIPT_ROOT}/../vault/vault-env.sh"
|
||||
|
||||
# Admin users who can merge vault PRs (from issue #77)
|
||||
# Comma-separated list of Forgejo usernames with admin role
|
||||
ADMIN_USERS="${FORGE_ADMIN_USERS:-vault-bot,admin}"
|
||||
|
||||
# Persistent log file for dispatcher
|
||||
DISPATCHER_LOG_FILE="${DISINTO_LOG_DIR:-/tmp}/dispatcher/dispatcher.log"
|
||||
mkdir -p "$(dirname "$DISPATCHER_LOG_FILE")"
|
||||
|
||||
# Log function with standardized format
|
||||
log() {
|
||||
local agent="${LOG_AGENT:-dispatcher}"
|
||||
printf '[%s] %s: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$agent" "$*" >> "$DISPATCHER_LOG_FILE"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Forge API helpers for admin verification
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Check if a user has admin role
|
||||
# Usage: is_user_admin <username>
|
||||
# Returns: 0=yes, 1=no
|
||||
is_user_admin() {
|
||||
local username="$1"
|
||||
local user_json
|
||||
|
||||
# Use admin token for API check (Forgejo only exposes is_admin: true
|
||||
# when the requesting user is also a site admin)
|
||||
local admin_token="${FORGE_ADMIN_TOKEN:-${FORGE_TOKEN}}"
|
||||
|
||||
# Fetch user info from Forgejo API
|
||||
user_json=$(curl -sf -H "Authorization: token ${admin_token}" \
|
||||
"${FORGE_URL}/api/v1/users/${username}" 2>/dev/null) || return 1
|
||||
|
||||
# Forgejo uses .is_admin for site-wide admin users
|
||||
local is_admin
|
||||
is_admin=$(echo "$user_json" | jq -r '.is_admin // false' 2>/dev/null) || return 1
|
||||
|
||||
if [[ "$is_admin" == "true" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Check if a user is in the allowed admin list
|
||||
# Usage: is_allowed_admin <username>
|
||||
# Returns: 0=yes, 1=no
|
||||
is_allowed_admin() {
|
||||
local username="$1"
|
||||
local admin_list
|
||||
admin_list=$(echo "$ADMIN_USERS" | tr ',' '\n')
|
||||
|
||||
while IFS= read -r admin; do
|
||||
admin=$(echo "$admin" | xargs) # trim whitespace
|
||||
if [[ "$username" == "$admin" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done <<< "$admin_list"
|
||||
|
||||
# Also check via API if not in static list
|
||||
if is_user_admin "$username"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Get the PR that introduced a specific file to vault/actions
|
||||
# Usage: get_pr_for_file <file_path>
|
||||
# Returns: PR number or empty if not found via PR
|
||||
get_pr_for_file() {
|
||||
local file_path="$1"
|
||||
local file_name
|
||||
file_name=$(basename "$file_path")
|
||||
|
||||
# Step 1: find the commit that added the file
|
||||
local add_commit
|
||||
add_commit=$(git -C "$OPS_REPO_ROOT" log --diff-filter=A --format="%H" \
|
||||
-- "vault/actions/${file_name}" 2>/dev/null | head -1)
|
||||
|
||||
if [ -z "$add_commit" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Step 2: find the merge commit that contains it via ancestry path
|
||||
local merge_line
|
||||
# Use --reverse to get the oldest (direct PR merge) first, not the newest
|
||||
merge_line=$(git -C "$OPS_REPO_ROOT" log --merges --ancestry-path \
|
||||
--reverse "${add_commit}..HEAD" --oneline 2>/dev/null | head -1)
|
||||
|
||||
if [ -z "$merge_line" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Step 3: extract PR number from merge commit message
|
||||
# Forgejo format: "Merge pull request 'title' (#N) from branch into main"
|
||||
local pr_num
|
||||
pr_num=$(echo "$merge_line" | grep -oE '#[0-9]+' | head -1 | tr -d '#')
|
||||
|
||||
if [ -n "$pr_num" ]; then
|
||||
echo "$pr_num"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Get PR merger info
|
||||
# Usage: get_pr_merger <pr_number>
|
||||
# Returns: JSON with merger username and merged timestamp
|
||||
get_pr_merger() {
|
||||
local pr_number="$1"
|
||||
|
||||
# Use ops repo API URL for PR lookups (not disinto repo)
|
||||
local ops_api="${FORGE_URL}/api/v1/repos/${FORGE_OPS_REPO}"
|
||||
|
||||
curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${ops_api}/pulls/${pr_number}" 2>/dev/null | jq -r '{
|
||||
username: .merge_user?.login // .user?.login,
|
||||
merged: .merged,
|
||||
merged_at: .merged_at // empty
|
||||
}'
|
||||
}
|
||||
|
||||
# Get PR reviews
|
||||
# Usage: get_pr_reviews <pr_number>
|
||||
# Returns: JSON array of reviews with reviewer login and state
|
||||
get_pr_reviews() {
|
||||
local pr_number="$1"
|
||||
|
||||
# Use ops repo API URL for PR lookups (not disinto repo)
|
||||
local ops_api="${FORGE_URL}/api/v1/repos/${FORGE_OPS_REPO}"
|
||||
|
||||
curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${ops_api}/pulls/${pr_number}/reviews" 2>/dev/null
|
||||
}
|
||||
|
||||
# Verify vault action was approved by an admin via PR review
|
||||
# Usage: verify_admin_approver <pr_number> <action_id>
|
||||
# Returns: 0=verified, 1=not verified
|
||||
verify_admin_approver() {
|
||||
local pr_number="$1"
|
||||
local action_id="$2"
|
||||
|
||||
# Fetch reviews for this PR
|
||||
local reviews_json
|
||||
reviews_json=$(get_pr_reviews "$pr_number") || {
|
||||
log "WARNING: Could not fetch reviews for PR #${pr_number} — skipping"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Check if there are any reviews
|
||||
local review_count
|
||||
review_count=$(echo "$reviews_json" | jq 'length // 0')
|
||||
if [ "$review_count" -eq 0 ]; then
|
||||
log "WARNING: No reviews found for PR #${pr_number} — rejecting"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check each review for admin approval
|
||||
local review
|
||||
while IFS= read -r review; do
|
||||
local reviewer state
|
||||
reviewer=$(echo "$review" | jq -r '.user?.login // empty')
|
||||
state=$(echo "$review" | jq -r '.state // empty')
|
||||
|
||||
# Skip non-APPROVED reviews
|
||||
if [ "$state" != "APPROVED" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Skip if no reviewer
|
||||
if [ -z "$reviewer" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if reviewer is admin
|
||||
if is_allowed_admin "$reviewer"; then
|
||||
log "Verified: PR #${pr_number} approved by admin '${reviewer}'"
|
||||
return 0
|
||||
fi
|
||||
done < <(echo "$reviews_json" | jq -c '.[]')
|
||||
|
||||
log "WARNING: No admin approval found for PR #${pr_number} — rejecting"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Verify vault action arrived via admin-merged PR
|
||||
# Usage: verify_admin_merged <toml_file>
|
||||
# Returns: 0=verified, 1=not verified
|
||||
#
|
||||
# Verification order (for auto-merge workflow):
|
||||
# 1. Check PR reviews for admin APPROVED state (primary check for auto-merge)
|
||||
# 2. Fallback: Check if merger is admin (backwards compat for manual merges)
|
||||
#
|
||||
# This handles the case where auto-merge is performed by a bot (dev-bot)
|
||||
# but the actual approval came from an admin reviewer.
|
||||
verify_admin_merged() {
|
||||
local toml_file="$1"
|
||||
local action_id
|
||||
action_id=$(basename "$toml_file" .toml)
|
||||
|
||||
# Get the PR that introduced this file
|
||||
local pr_num
|
||||
pr_num=$(get_pr_for_file "$toml_file") || {
|
||||
log "WARNING: No PR found for action ${action_id} — skipping (possible direct push)"
|
||||
return 1
|
||||
}
|
||||
|
||||
log "Action ${action_id} arrived via PR #${pr_num}"
|
||||
|
||||
# First, try admin approver check (for auto-merge workflow)
|
||||
if verify_admin_approver "$pr_num" "$action_id"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Fallback: Check merger (backwards compatibility for manual merges)
|
||||
local merger_json
|
||||
merger_json=$(get_pr_merger "$pr_num") || {
|
||||
log "WARNING: Could not fetch PR #${pr_num} details — skipping"
|
||||
return 1
|
||||
}
|
||||
|
||||
local merged merger_username
|
||||
merged=$(echo "$merger_json" | jq -r '.merged // false')
|
||||
merger_username=$(echo "$merger_json" | jq -r '.username // empty')
|
||||
|
||||
# Check if PR is merged
|
||||
if [[ "$merged" != "true" ]]; then
|
||||
log "WARNING: PR #${pr_num} is not merged — skipping"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if merger is admin
|
||||
if [ -z "$merger_username" ]; then
|
||||
log "WARNING: Could not determine PR #${pr_num} merger — skipping"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! is_allowed_admin "$merger_username"; then
|
||||
log "WARNING: PR #${pr_num} merged by non-admin user '${merger_username}' — skipping"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Verified: PR #${pr_num} merged by admin '${merger_username}' (fallback check)"
|
||||
return 0
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Vault action processing
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Check if an action has already been completed
|
||||
is_action_completed() {
|
||||
local id="$1"
|
||||
[ -f "${VAULT_ACTIONS_DIR}/${id}.result.json" ]
|
||||
}
|
||||
|
||||
# Validate a vault action TOML file
|
||||
# Usage: validate_action <toml_file>
|
||||
# Sets: VAULT_ACTION_ID, VAULT_ACTION_FORMULA, VAULT_ACTION_CONTEXT, VAULT_ACTION_SECRETS
|
||||
validate_action() {
|
||||
local toml_file="$1"
|
||||
|
||||
# Source vault-env.sh for validate_vault_action function
|
||||
if [ ! -f "$VAULT_ENV" ]; then
|
||||
echo "ERROR: vault-env.sh not found at ${VAULT_ENV}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! source "$VAULT_ENV"; then
|
||||
echo "ERROR: failed to source vault-env.sh" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! validate_vault_action "$toml_file"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Write result file for an action
|
||||
# Usage: write_result <action_id> <exit_code> <logs>
|
||||
write_result() {
|
||||
local action_id="$1"
|
||||
local exit_code="$2"
|
||||
local logs="$3"
|
||||
|
||||
local result_file="${VAULT_ACTIONS_DIR}/${action_id}.result.json"
|
||||
|
||||
# Truncate logs if too long (keep last 1000 chars)
|
||||
if [ ${#logs} -gt 1000 ]; then
|
||||
logs="${logs: -1000}"
|
||||
fi
|
||||
|
||||
# Write result JSON
|
||||
jq -n \
|
||||
--arg id "$action_id" \
|
||||
--argjson exit_code "$exit_code" \
|
||||
--arg timestamp "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
|
||||
--arg logs "$logs" \
|
||||
'{id: $id, exit_code: $exit_code, timestamp: $timestamp, logs: $logs}' \
|
||||
> "$result_file"
|
||||
|
||||
log "Result written: ${result_file}"
|
||||
}
|
||||
|
||||
# Launch runner for the given action
|
||||
# Usage: launch_runner <toml_file>
|
||||
launch_runner() {
|
||||
local toml_file="$1"
|
||||
local action_id
|
||||
action_id=$(basename "$toml_file" .toml)
|
||||
|
||||
log "Launching runner for action: ${action_id}"
|
||||
|
||||
# Validate TOML
|
||||
if ! validate_action "$toml_file"; then
|
||||
log "ERROR: Action validation failed for ${action_id}"
|
||||
write_result "$action_id" 1 "Validation failed: see logs above"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Verify admin merge
|
||||
if ! verify_admin_merged "$toml_file"; then
|
||||
log "ERROR: Admin merge verification failed for ${action_id}"
|
||||
write_result "$action_id" 1 "Admin merge verification failed: see logs above"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Extract secrets from validated action
|
||||
local secrets_array
|
||||
secrets_array="${VAULT_ACTION_SECRETS:-}"
|
||||
|
||||
# Build command array (safe from shell injection)
|
||||
local -a cmd=(docker run --rm
|
||||
--name "vault-runner-${action_id}"
|
||||
--network disinto_disinto-net
|
||||
-e "FORGE_URL=${FORGE_URL}"
|
||||
-e "FORGE_TOKEN=${FORGE_TOKEN}"
|
||||
-e "FORGE_REPO=${FORGE_REPO}"
|
||||
-e "FORGE_OPS_REPO=${FORGE_OPS_REPO}"
|
||||
-e "PRIMARY_BRANCH=${PRIMARY_BRANCH}"
|
||||
-e DISINTO_CONTAINER=1
|
||||
)
|
||||
|
||||
# Add environment variables for secrets (if any declared)
|
||||
if [ -n "$secrets_array" ]; then
|
||||
for secret in $secrets_array; do
|
||||
secret=$(echo "$secret" | xargs)
|
||||
if [ -n "$secret" ]; then
|
||||
# Verify secret exists in vault
|
||||
if [ -z "${!secret:-}" ]; then
|
||||
log "ERROR: Secret '${secret}' not found in vault for action ${action_id}"
|
||||
write_result "$action_id" 1 "Secret not found in vault: ${secret}"
|
||||
return 1
|
||||
fi
|
||||
cmd+=(-e "${secret}=${!secret}")
|
||||
fi
|
||||
done
|
||||
else
|
||||
log "Action ${action_id} has no secrets declared — runner will execute without extra env vars"
|
||||
fi
|
||||
|
||||
# Add formula and action id as arguments (safe from shell injection)
|
||||
local formula="${VAULT_ACTION_FORMULA:-}"
|
||||
cmd+=(disinto-agents:latest bash -c
|
||||
"cd /home/agent/disinto && bash formulas/${formula}.sh ${action_id}")
|
||||
|
||||
# Log command skeleton (hide all -e flags for security)
|
||||
local -a log_cmd=()
|
||||
local skip_next=0
|
||||
for arg in "${cmd[@]}"; do
|
||||
if [[ $skip_next -eq 1 ]]; then
|
||||
skip_next=0
|
||||
continue
|
||||
fi
|
||||
if [[ "$arg" == "-e" ]]; then
|
||||
log_cmd+=("$arg" "<redacted>")
|
||||
skip_next=1
|
||||
else
|
||||
log_cmd+=("$arg")
|
||||
fi
|
||||
done
|
||||
log "Running: ${log_cmd[*]}"
|
||||
|
||||
# Create temp file for logs
|
||||
local log_file
|
||||
log_file=$(mktemp /tmp/dispatcher-logs-XXXXXX.txt)
|
||||
trap 'rm -f "$log_file"' RETURN
|
||||
|
||||
# Execute with array expansion (safe from shell injection)
|
||||
# Capture stdout and stderr to log file
|
||||
"${cmd[@]}" > "$log_file" 2>&1
|
||||
local exit_code=$?
|
||||
|
||||
# Read logs summary
|
||||
local logs
|
||||
logs=$(cat "$log_file")
|
||||
|
||||
# Write result file
|
||||
write_result "$action_id" "$exit_code" "$logs"
|
||||
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
log "Runner completed successfully for action: ${action_id}"
|
||||
else
|
||||
log "Runner failed for action: ${action_id} (exit code: ${exit_code})"
|
||||
fi
|
||||
|
||||
return $exit_code
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Reproduce dispatch — launch sidecar for bug-report issues
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Check if a reproduce run is already in-flight for a given issue.
|
||||
# Uses a simple pid-file in /tmp so we don't double-launch per dispatcher cycle.
|
||||
_reproduce_lockfile() {
|
||||
local issue="$1"
|
||||
echo "/tmp/reproduce-inflight-${issue}.pid"
|
||||
}
|
||||
|
||||
is_reproduce_running() {
|
||||
local issue="$1"
|
||||
local pidfile
|
||||
pidfile=$(_reproduce_lockfile "$issue")
|
||||
[ -f "$pidfile" ] || return 1
|
||||
local pid
|
||||
pid=$(cat "$pidfile" 2>/dev/null || echo "")
|
||||
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
|
||||
}
|
||||
|
||||
# Fetch open issues labelled bug-report that have no outcome label yet.
|
||||
# Returns a newline-separated list of "issue_number:project_toml" pairs.
|
||||
fetch_reproduce_candidates() {
|
||||
# Require FORGE_TOKEN, FORGE_URL, FORGE_REPO
|
||||
[ -n "${FORGE_TOKEN:-}" ] || return 0
|
||||
[ -n "${FORGE_URL:-}" ] || return 0
|
||||
[ -n "${FORGE_REPO:-}" ] || return 0
|
||||
|
||||
local api="${FORGE_URL}/api/v1/repos/${FORGE_REPO}"
|
||||
|
||||
local issues_json
|
||||
issues_json=$(curl -sf \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${api}/issues?type=issues&state=open&labels=bug-report&limit=20" 2>/dev/null) || return 0
|
||||
|
||||
# Filter out issues that already carry an outcome label.
|
||||
# Write JSON to a temp file so python3 can read from stdin (heredoc) and
|
||||
# still receive the JSON as an argument (avoids SC2259: pipe vs heredoc).
|
||||
local tmpjson
|
||||
tmpjson=$(mktemp)
|
||||
echo "$issues_json" > "$tmpjson"
|
||||
python3 - "$tmpjson" <<'PYEOF'
|
||||
import sys, json
|
||||
data = json.load(open(sys.argv[1]))
|
||||
skip = {"in-progress", "in-triage", "rejected", "blocked"}
|
||||
for issue in data:
|
||||
labels = {l["name"] for l in (issue.get("labels") or [])}
|
||||
if labels & skip:
|
||||
continue
|
||||
print(issue["number"])
|
||||
PYEOF
|
||||
rm -f "$tmpjson"
|
||||
}
|
||||
|
||||
# Launch one reproduce container per candidate issue.
|
||||
# project_toml is resolved from FACTORY_ROOT/projects/*.toml (first match).
|
||||
dispatch_reproduce() {
|
||||
local issue_number="$1"
|
||||
|
||||
if is_reproduce_running "$issue_number"; then
|
||||
log "Reproduce already running for issue #${issue_number}, skipping"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Find first project TOML available (same convention as dev-poll)
|
||||
local project_toml=""
|
||||
for toml in "${FACTORY_ROOT}"/projects/*.toml; do
|
||||
[ -f "$toml" ] && { project_toml="$toml"; break; }
|
||||
done
|
||||
|
||||
if [ -z "$project_toml" ]; then
|
||||
log "WARNING: no project TOML found under ${FACTORY_ROOT}/projects/ — skipping reproduce for #${issue_number}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Dispatching reproduce-agent for issue #${issue_number} (project: ${project_toml})"
|
||||
|
||||
# Build docker run command using array (safe from injection)
|
||||
local -a cmd=(docker run --rm
|
||||
--name "disinto-reproduce-${issue_number}"
|
||||
--network host
|
||||
--security-opt apparmor=unconfined
|
||||
-v /var/run/docker.sock:/var/run/docker.sock
|
||||
-v agent-data:/home/agent/data
|
||||
-v project-repos:/home/agent/repos
|
||||
-e "FORGE_URL=${FORGE_URL}"
|
||||
-e "FORGE_TOKEN=${FORGE_TOKEN}"
|
||||
-e "FORGE_REPO=${FORGE_REPO}"
|
||||
-e "PRIMARY_BRANCH=${PRIMARY_BRANCH:-main}"
|
||||
-e DISINTO_CONTAINER=1
|
||||
)
|
||||
|
||||
# Pass through ANTHROPIC_API_KEY if set
|
||||
if [ -n "${ANTHROPIC_API_KEY:-}" ]; then
|
||||
cmd+=(-e "ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}")
|
||||
fi
|
||||
|
||||
# Mount ~/.claude and ~/.ssh from the runtime user's home if available
|
||||
local runtime_home="${HOME:-/home/debian}"
|
||||
if [ -d "${runtime_home}/.claude" ]; then
|
||||
cmd+=(-v "${runtime_home}/.claude:/home/agent/.claude")
|
||||
fi
|
||||
if [ -f "${runtime_home}/.claude.json" ]; then
|
||||
cmd+=(-v "${runtime_home}/.claude.json:/home/agent/.claude.json:ro")
|
||||
fi
|
||||
if [ -d "${runtime_home}/.ssh" ]; then
|
||||
cmd+=(-v "${runtime_home}/.ssh:/home/agent/.ssh:ro")
|
||||
fi
|
||||
# Mount claude CLI binary if present on host
|
||||
if [ -f /usr/local/bin/claude ]; then
|
||||
cmd+=(-v /usr/local/bin/claude:/usr/local/bin/claude:ro)
|
||||
fi
|
||||
|
||||
# Mount the project TOML into the container at a stable path
|
||||
local container_toml="/home/agent/project.toml"
|
||||
cmd+=(-v "${project_toml}:${container_toml}:ro")
|
||||
|
||||
cmd+=(disinto-reproduce:latest "$container_toml" "$issue_number")
|
||||
|
||||
# Launch in background; write pid-file so we don't double-launch
|
||||
"${cmd[@]}" &
|
||||
local bg_pid=$!
|
||||
echo "$bg_pid" > "$(_reproduce_lockfile "$issue_number")"
|
||||
log "Reproduce container launched (pid ${bg_pid}) for issue #${issue_number}"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Triage dispatch — launch sidecar for bug-report + in-triage issues
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Check if a triage run is already in-flight for a given issue.
|
||||
_triage_lockfile() {
|
||||
local issue="$1"
|
||||
echo "/tmp/triage-inflight-${issue}.pid"
|
||||
}
|
||||
|
||||
is_triage_running() {
|
||||
local issue="$1"
|
||||
local pidfile
|
||||
pidfile=$(_triage_lockfile "$issue")
|
||||
[ -f "$pidfile" ] || return 1
|
||||
local pid
|
||||
pid=$(cat "$pidfile" 2>/dev/null || echo "")
|
||||
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
|
||||
}
|
||||
|
||||
# Fetch open issues labelled both bug-report and in-triage.
|
||||
# Returns a newline-separated list of issue numbers.
|
||||
fetch_triage_candidates() {
|
||||
# Require FORGE_TOKEN, FORGE_URL, FORGE_REPO
|
||||
[ -n "${FORGE_TOKEN:-}" ] || return 0
|
||||
[ -n "${FORGE_URL:-}" ] || return 0
|
||||
[ -n "${FORGE_REPO:-}" ] || return 0
|
||||
|
||||
local api="${FORGE_URL}/api/v1/repos/${FORGE_REPO}"
|
||||
|
||||
local issues_json
|
||||
issues_json=$(curl -sf \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${api}/issues?type=issues&state=open&labels=bug-report&limit=20" 2>/dev/null) || return 0
|
||||
|
||||
# Filter to issues that carry BOTH bug-report AND in-triage labels.
|
||||
local tmpjson
|
||||
tmpjson=$(mktemp)
|
||||
echo "$issues_json" > "$tmpjson"
|
||||
python3 - "$tmpjson" <<'PYEOF'
|
||||
import sys, json
|
||||
data = json.load(open(sys.argv[1]))
|
||||
for issue in data:
|
||||
labels = {l["name"] for l in (issue.get("labels") or [])}
|
||||
if "bug-report" in labels and "in-triage" in labels:
|
||||
print(issue["number"])
|
||||
PYEOF
|
||||
rm -f "$tmpjson"
|
||||
}
|
||||
|
||||
# Launch one triage container per candidate issue.
|
||||
# Uses the same disinto-reproduce:latest image as the reproduce-agent,
|
||||
# selecting the triage formula via DISINTO_FORMULA env var.
|
||||
# Stack lock is held for the full run (no timeout).
|
||||
dispatch_triage() {
|
||||
local issue_number="$1"
|
||||
|
||||
if is_triage_running "$issue_number"; then
|
||||
log "Triage already running for issue #${issue_number}, skipping"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Find first project TOML available (same convention as dev-poll)
|
||||
local project_toml=""
|
||||
for toml in "${FACTORY_ROOT}"/projects/*.toml; do
|
||||
[ -f "$toml" ] && { project_toml="$toml"; break; }
|
||||
done
|
||||
|
||||
if [ -z "$project_toml" ]; then
|
||||
log "WARNING: no project TOML found under ${FACTORY_ROOT}/projects/ — skipping triage for #${issue_number}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Dispatching triage-agent for issue #${issue_number} (project: ${project_toml})"
|
||||
|
||||
# Build docker run command using array (safe from injection)
|
||||
local -a cmd=(docker run --rm
|
||||
--name "disinto-triage-${issue_number}"
|
||||
--network host
|
||||
--security-opt apparmor=unconfined
|
||||
-v /var/run/docker.sock:/var/run/docker.sock
|
||||
-v agent-data:/home/agent/data
|
||||
-v project-repos:/home/agent/repos
|
||||
-e "FORGE_URL=${FORGE_URL}"
|
||||
-e "FORGE_TOKEN=${FORGE_TOKEN}"
|
||||
-e "FORGE_REPO=${FORGE_REPO}"
|
||||
-e "PRIMARY_BRANCH=${PRIMARY_BRANCH:-main}"
|
||||
-e DISINTO_CONTAINER=1
|
||||
-e DISINTO_FORMULA=triage
|
||||
)
|
||||
|
||||
# Pass through ANTHROPIC_API_KEY if set
|
||||
if [ -n "${ANTHROPIC_API_KEY:-}" ]; then
|
||||
cmd+=(-e "ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}")
|
||||
fi
|
||||
|
||||
# Mount ~/.claude and ~/.ssh from the runtime user's home if available
|
||||
local runtime_home="${HOME:-/home/debian}"
|
||||
if [ -d "${runtime_home}/.claude" ]; then
|
||||
cmd+=(-v "${runtime_home}/.claude:/home/agent/.claude")
|
||||
fi
|
||||
if [ -f "${runtime_home}/.claude.json" ]; then
|
||||
cmd+=(-v "${runtime_home}/.claude.json:/home/agent/.claude.json:ro")
|
||||
fi
|
||||
if [ -d "${runtime_home}/.ssh" ]; then
|
||||
cmd+=(-v "${runtime_home}/.ssh:/home/agent/.ssh:ro")
|
||||
fi
|
||||
# Mount claude CLI binary if present on host
|
||||
if [ -f /usr/local/bin/claude ]; then
|
||||
cmd+=(-v /usr/local/bin/claude:/usr/local/bin/claude:ro)
|
||||
fi
|
||||
|
||||
# Mount the project TOML into the container at a stable path
|
||||
local container_toml="/home/agent/project.toml"
|
||||
cmd+=(-v "${project_toml}:${container_toml}:ro")
|
||||
|
||||
cmd+=(disinto-reproduce:latest "$container_toml" "$issue_number")
|
||||
|
||||
# Launch in background; write pid-file so we don't double-launch
|
||||
"${cmd[@]}" &
|
||||
local bg_pid=$!
|
||||
echo "$bg_pid" > "$(_triage_lockfile "$issue_number")"
|
||||
log "Triage container launched (pid ${bg_pid}) for issue #${issue_number}"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Main dispatcher loop
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Clone or pull the ops repo
|
||||
ensure_ops_repo() {
|
||||
if [ ! -d "${OPS_REPO_ROOT}/.git" ]; then
|
||||
log "Cloning ops repo from ${FORGE_URL}/${FORGE_OPS_REPO}..."
|
||||
git clone "${FORGE_URL}/${FORGE_OPS_REPO}" "${OPS_REPO_ROOT}"
|
||||
else
|
||||
log "Pulling latest ops repo changes..."
|
||||
(cd "${OPS_REPO_ROOT}" && git pull --rebase)
|
||||
fi
|
||||
}
|
||||
|
||||
# Main dispatcher loop
|
||||
main() {
|
||||
log "Starting dispatcher..."
|
||||
log "Polling ops repo: ${VAULT_ACTIONS_DIR}"
|
||||
log "Admin users: ${ADMIN_USERS}"
|
||||
|
||||
while true; do
|
||||
# Refresh ops repo at the start of each poll cycle
|
||||
ensure_ops_repo
|
||||
|
||||
# Check if actions directory exists
|
||||
if [ ! -d "${VAULT_ACTIONS_DIR}" ]; then
|
||||
log "Actions directory not found: ${VAULT_ACTIONS_DIR}"
|
||||
sleep 60
|
||||
continue
|
||||
fi
|
||||
|
||||
# Process each action file
|
||||
for toml_file in "${VAULT_ACTIONS_DIR}"/*.toml; do
|
||||
# Handle case where no .toml files exist
|
||||
[ -e "$toml_file" ] || continue
|
||||
|
||||
local action_id
|
||||
action_id=$(basename "$toml_file" .toml)
|
||||
|
||||
# Skip if already completed
|
||||
if is_action_completed "$action_id"; then
|
||||
log "Action ${action_id} already completed, skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Launch runner for this action
|
||||
launch_runner "$toml_file" || true
|
||||
done
|
||||
|
||||
# Reproduce dispatch: check for bug-report issues needing reproduction
|
||||
local candidate_issues
|
||||
candidate_issues=$(fetch_reproduce_candidates) || true
|
||||
if [ -n "$candidate_issues" ]; then
|
||||
while IFS= read -r issue_num; do
|
||||
[ -n "$issue_num" ] || continue
|
||||
dispatch_reproduce "$issue_num" || true
|
||||
done <<< "$candidate_issues"
|
||||
fi
|
||||
|
||||
# Triage dispatch: check for bug-report + in-triage issues needing deep analysis
|
||||
local triage_issues
|
||||
triage_issues=$(fetch_triage_candidates) || true
|
||||
if [ -n "$triage_issues" ]; then
|
||||
while IFS= read -r issue_num; do
|
||||
[ -n "$issue_num" ] || continue
|
||||
dispatch_triage "$issue_num" || true
|
||||
done <<< "$triage_issues"
|
||||
fi
|
||||
|
||||
# Wait before next poll
|
||||
sleep 60
|
||||
done
|
||||
}
|
||||
|
||||
# Run main
|
||||
main "$@"
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Set USER before sourcing env.sh (Alpine doesn't set USER)
|
||||
export USER="${USER:-root}"
|
||||
|
||||
FORGE_URL="${FORGE_URL:-http://forgejo:3000}"
|
||||
FORGE_REPO="${FORGE_REPO:-disinto-admin/disinto}"
|
||||
|
||||
# Shallow clone at the pinned version (inject token to support auth-required Forgejo)
|
||||
if [ ! -d /opt/disinto/.git ]; then
|
||||
_auth_url=$(printf '%s' "$FORGE_URL" | sed "s|://|://token:${FORGE_TOKEN}@|")
|
||||
git clone --depth 1 --branch "${DISINTO_VERSION:-main}" "${_auth_url}/${FORGE_REPO}.git" /opt/disinto
|
||||
fi
|
||||
|
||||
# Start dispatcher in background
|
||||
bash /opt/disinto/docker/edge/dispatcher.sh &
|
||||
|
||||
# Start supervisor loop in background
|
||||
while true; do
|
||||
bash /opt/disinto/supervisor/supervisor-run.sh /opt/disinto/projects/disinto.toml 2>&1 | tee -a /opt/disinto-logs/supervisor.log || true
|
||||
sleep 1200 # 20 minutes
|
||||
done &
|
||||
|
||||
# Caddy as main process
|
||||
exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nothing shipped yet</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
p {
|
||||
font-size: 1.25rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Nothing shipped yet</h1>
|
||||
<p>CI pipelines will update this page with your staging artifacts.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
bash curl git jq docker.io docker-compose-plugin \
|
||||
nodejs npm chromium \
|
||||
&& npm install -g @anthropic-ai/mcp-playwright \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN useradd -m -u 1000 -s /bin/bash agent
|
||||
COPY docker/reproduce/entrypoint-reproduce.sh /entrypoint-reproduce.sh
|
||||
RUN chmod +x /entrypoint-reproduce.sh
|
||||
WORKDIR /home/agent
|
||||
ENTRYPOINT ["/entrypoint-reproduce.sh"]
|
||||
|
|
@ -1,766 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# entrypoint-reproduce.sh — Reproduce-agent sidecar entrypoint
|
||||
#
|
||||
# Acquires the stack lock, boots the project stack (if formula declares
|
||||
# stack_script), then drives Claude + Playwright MCP to follow the bug
|
||||
# report's repro steps. Labels the issue based on outcome and posts
|
||||
# findings + screenshots.
|
||||
#
|
||||
# Usage (launched by dispatcher.sh):
|
||||
# entrypoint-reproduce.sh <project_toml> <issue_number>
|
||||
#
|
||||
# Environment (injected by dispatcher via docker run -e):
|
||||
# FORGE_URL, FORGE_TOKEN, FORGE_REPO, PRIMARY_BRANCH, DISINTO_CONTAINER=1
|
||||
#
|
||||
# Volumes expected:
|
||||
# /home/agent/data — agent-data volume (stack-lock files go here)
|
||||
# /home/agent/repos — project-repos volume
|
||||
# /home/agent/.claude — host ~/.claude (OAuth credentials)
|
||||
# /home/agent/.ssh — host ~/.ssh (read-only)
|
||||
# /usr/local/bin/claude — host claude CLI binary (read-only)
|
||||
# /var/run/docker.sock — host docker socket
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DISINTO_DIR="${DISINTO_DIR:-/home/agent/disinto}"
|
||||
|
||||
# Select formula based on DISINTO_FORMULA env var (set by dispatcher)
|
||||
case "${DISINTO_FORMULA:-reproduce}" in
|
||||
triage)
|
||||
ACTIVE_FORMULA="${DISINTO_DIR}/formulas/triage.toml"
|
||||
;;
|
||||
*)
|
||||
ACTIVE_FORMULA="${DISINTO_DIR}/formulas/reproduce.toml"
|
||||
;;
|
||||
esac
|
||||
|
||||
REPRODUCE_TIMEOUT="${REPRODUCE_TIMEOUT_MINUTES:-15}"
|
||||
LOGFILE="/home/agent/data/logs/reproduce.log"
|
||||
SCREENSHOT_DIR="/home/agent/data/screenshots"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Determine agent type early for log prefix
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "${DISINTO_FORMULA:-reproduce}" = "triage" ]; then
|
||||
AGENT_TYPE="triage"
|
||||
else
|
||||
AGENT_TYPE="reproduce"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging
|
||||
# ---------------------------------------------------------------------------
|
||||
log() {
|
||||
printf '[%s] %s: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$AGENT_TYPE" "$*" | tee -a "$LOGFILE"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argument validation
|
||||
# ---------------------------------------------------------------------------
|
||||
PROJECT_TOML="${1:-}"
|
||||
ISSUE_NUMBER="${2:-}"
|
||||
|
||||
if [ -z "$PROJECT_TOML" ] || [ -z "$ISSUE_NUMBER" ]; then
|
||||
log "FATAL: usage: entrypoint-reproduce.sh <project_toml> <issue_number>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$PROJECT_TOML" ]; then
|
||||
log "FATAL: project TOML not found: ${PROJECT_TOML}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bootstrap: directories, env
|
||||
# ---------------------------------------------------------------------------
|
||||
mkdir -p /home/agent/data/logs /home/agent/data/locks "$SCREENSHOT_DIR"
|
||||
|
||||
export DISINTO_CONTAINER=1
|
||||
export HOME="${HOME:-/home/agent}"
|
||||
export USER="${USER:-agent}"
|
||||
|
||||
FORGE_API="${FORGE_URL}/api/v1/repos/${FORGE_REPO}"
|
||||
|
||||
# Load project name from TOML
|
||||
PROJECT_NAME=$(python3 -c "
|
||||
import sys, tomllib
|
||||
with open(sys.argv[1], 'rb') as f:
|
||||
print(tomllib.load(f)['name'])
|
||||
" "$PROJECT_TOML" 2>/dev/null) || {
|
||||
log "FATAL: could not read project name from ${PROJECT_TOML}"
|
||||
exit 1
|
||||
}
|
||||
export PROJECT_NAME
|
||||
|
||||
PROJECT_REPO_ROOT="/home/agent/repos/${PROJECT_NAME}"
|
||||
|
||||
if [ "$AGENT_TYPE" = "triage" ]; then
|
||||
log "Starting triage-agent for issue #${ISSUE_NUMBER} (project: ${PROJECT_NAME})"
|
||||
else
|
||||
log "Starting reproduce-agent for issue #${ISSUE_NUMBER} (project: ${PROJECT_NAME})"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verify claude CLI is available (mounted from host)
|
||||
# ---------------------------------------------------------------------------
|
||||
if ! command -v claude &>/dev/null; then
|
||||
log "FATAL: claude CLI not found. Mount the host binary at /usr/local/bin/claude"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source stack-lock library
|
||||
# ---------------------------------------------------------------------------
|
||||
# shellcheck source=/home/agent/disinto/lib/stack-lock.sh
|
||||
source "${DISINTO_DIR}/lib/stack-lock.sh"
|
||||
|
||||
LOCK_HOLDER="reproduce-agent-${ISSUE_NUMBER}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read formula config
|
||||
# ---------------------------------------------------------------------------
|
||||
FORMULA_STACK_SCRIPT=""
|
||||
FORMULA_TIMEOUT_MINUTES="${REPRODUCE_TIMEOUT}"
|
||||
|
||||
if [ -f "$ACTIVE_FORMULA" ]; then
|
||||
FORMULA_STACK_SCRIPT=$(python3 -c "
|
||||
import sys, tomllib
|
||||
with open(sys.argv[1], 'rb') as f:
|
||||
d = tomllib.load(f)
|
||||
print(d.get('stack_script', ''))
|
||||
" "$ACTIVE_FORMULA" 2>/dev/null || echo "")
|
||||
|
||||
_tm=$(python3 -c "
|
||||
import sys, tomllib
|
||||
with open(sys.argv[1], 'rb') as f:
|
||||
d = tomllib.load(f)
|
||||
print(d.get('timeout_minutes', '${REPRODUCE_TIMEOUT}'))
|
||||
" "$ACTIVE_FORMULA" 2>/dev/null || echo "${REPRODUCE_TIMEOUT}")
|
||||
FORMULA_TIMEOUT_MINUTES="$_tm"
|
||||
fi
|
||||
|
||||
log "Formula stack_script: '${FORMULA_STACK_SCRIPT}'"
|
||||
log "Formula timeout: ${FORMULA_TIMEOUT_MINUTES}m"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fetch issue details for repro steps
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Fetching issue #${ISSUE_NUMBER} from ${FORGE_API}..."
|
||||
ISSUE_JSON=$(curl -sf \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues/${ISSUE_NUMBER}" 2>/dev/null) || {
|
||||
log "ERROR: failed to fetch issue #${ISSUE_NUMBER}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
ISSUE_TITLE=$(echo "$ISSUE_JSON" | jq -r '.title // "unknown"')
|
||||
ISSUE_BODY=$(echo "$ISSUE_JSON" | jq -r '.body // ""')
|
||||
|
||||
log "Issue: ${ISSUE_TITLE}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Acquire stack lock
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Acquiring stack lock for project ${PROJECT_NAME}..."
|
||||
stack_lock_acquire "$LOCK_HOLDER" "$PROJECT_NAME" 900
|
||||
log "Stack lock acquired."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Start heartbeat in background (every 2 minutes)
|
||||
# ---------------------------------------------------------------------------
|
||||
heartbeat_loop() {
|
||||
while true; do
|
||||
sleep 120
|
||||
stack_lock_heartbeat "$LOCK_HOLDER" "$PROJECT_NAME" 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
heartbeat_loop &
|
||||
HEARTBEAT_PID=$!
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Debug branch cleanup trap (for triage-agent throwaway branches)
|
||||
# ---------------------------------------------------------------------------
|
||||
DEBUG_BRANCH="triage-debug-${ISSUE_NUMBER}"
|
||||
|
||||
# Combined EXIT trap: heartbeat kill + stack lock release + debug branch cleanup
|
||||
trap 'kill "$HEARTBEAT_PID" 2>/dev/null || true
|
||||
stack_lock_release "$PROJECT_NAME" "$LOCK_HOLDER" || true
|
||||
git -C "$PROJECT_REPO_ROOT" checkout "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
git -C "$PROJECT_REPO_ROOT" branch -D "$DEBUG_BRANCH" 2>/dev/null || true
|
||||
log "Cleanup completed (trap)"' EXIT
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boot the project stack if formula declares stack_script
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ -n "$FORMULA_STACK_SCRIPT" ] && [ -d "$PROJECT_REPO_ROOT" ]; then
|
||||
log "Running stack_script: ${FORMULA_STACK_SCRIPT}"
|
||||
# Run in project repo root; script path is relative to project repo.
|
||||
# Read stack_script into array to allow arguments (e.g. "scripts/dev.sh restart --full").
|
||||
read -ra _stack_cmd <<< "$FORMULA_STACK_SCRIPT"
|
||||
(cd "$PROJECT_REPO_ROOT" && bash "${_stack_cmd[@]}") || {
|
||||
log "WARNING: stack_script exited non-zero — continuing anyway"
|
||||
}
|
||||
# Give the stack a moment to stabilise
|
||||
sleep 5
|
||||
elif [ -n "$FORMULA_STACK_SCRIPT" ]; then
|
||||
log "WARNING: PROJECT_REPO_ROOT not found at ${PROJECT_REPO_ROOT} — skipping stack_script"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build Claude prompt based on agent type
|
||||
# ---------------------------------------------------------------------------
|
||||
TIMESTAMP=$(date -u '+%Y%m%d-%H%M%S')
|
||||
SCREENSHOT_PREFIX="${SCREENSHOT_DIR}/issue-${ISSUE_NUMBER}-${TIMESTAMP}"
|
||||
|
||||
if [ "$AGENT_TYPE" = "triage" ]; then
|
||||
# Triage-agent prompt: deep root cause analysis after reproduce-agent findings
|
||||
CLAUDE_PROMPT=$(cat <<PROMPT
|
||||
You are the triage-agent. Your task is to perform deep root cause analysis on issue #${ISSUE_NUMBER} after the reproduce-agent has confirmed the bug.
|
||||
|
||||
## Issue title
|
||||
${ISSUE_TITLE}
|
||||
|
||||
## Issue body
|
||||
${ISSUE_BODY}
|
||||
|
||||
## Your task — 6-step triage workflow
|
||||
|
||||
You have a defined 6-step workflow to follow. Budget your turns: ~70% on tracing, ~30% on instrumentation.
|
||||
|
||||
### Step 1: Read reproduce-agent findings
|
||||
Before doing anything else, parse all prior evidence from the issue comments.
|
||||
|
||||
1. Fetch the issue body and all comments:
|
||||
curl -sf -H "Authorization: token \${FORGE_TOKEN}" \
|
||||
"\${FORGE_API}/issues/\${ISSUE_NUMBER}" | jq -r '.body'
|
||||
curl -sf -H "Authorization: token \${FORGE_TOKEN}" \
|
||||
"\${FORGE_API}/issues/\${ISSUE_NUMBER}/comments" | jq -r '.[].body'
|
||||
|
||||
2. Identify the reproduce-agent comment (look for sections like
|
||||
"Reproduction steps", "Logs examined", "What was tried").
|
||||
|
||||
3. Extract and note:
|
||||
- The exact symptom (error message, unexpected value, visual regression)
|
||||
- Steps that reliably trigger the bug
|
||||
- Log lines or API responses already captured
|
||||
- Any hypotheses the reproduce-agent already ruled out
|
||||
|
||||
Do NOT repeat work the reproduce-agent already did. Your job starts where
|
||||
theirs ended. If no reproduce-agent comment is found, note it and proceed
|
||||
with fresh investigation using the issue body only.
|
||||
|
||||
### Step 2: Trace data flow from symptom to source
|
||||
Systematically follow the symptom backwards through each layer of the stack.
|
||||
|
||||
Generic layer traversal: UI → API → backend → data store
|
||||
|
||||
For each layer boundary:
|
||||
1. What does the upstream layer send?
|
||||
2. What does the downstream layer expect?
|
||||
3. Is there a mismatch? If yes — is this the root cause or a symptom?
|
||||
|
||||
Tracing checklist:
|
||||
a. Start at the layer closest to the visible symptom.
|
||||
b. Read the relevant source files — do not guess data shapes.
|
||||
c. Cross-reference API contracts: compare what the code sends vs what it
|
||||
should send according to schemas, type definitions, or documentation.
|
||||
d. Check recent git history on suspicious files:
|
||||
git log --oneline -20 -- <file>
|
||||
e. Search for related issues or TODOs in the code:
|
||||
grep -r "TODO\|FIXME\|HACK" -- <relevant directory>
|
||||
|
||||
Capture for each layer:
|
||||
- The data shape flowing in and out (field names, types, nullability)
|
||||
- Whether the layer's behavior matches its documented contract
|
||||
- Any discrepancy found
|
||||
|
||||
If a clear root cause becomes obvious during tracing, note it and continue
|
||||
checking whether additional causes exist downstream.
|
||||
|
||||
### Step 3: Add debug instrumentation on a throwaway branch
|
||||
Use ~30% of your total turn budget here. Only instrument after tracing has
|
||||
identified the most likely failure points — do not instrument blindly.
|
||||
|
||||
1. Create a throwaway debug branch (NEVER commit this to main):
|
||||
cd "\$PROJECT_REPO_ROOT"
|
||||
git checkout -b debug/triage-\${ISSUE_NUMBER}
|
||||
|
||||
2. Add targeted logging at the layer boundaries identified during tracing:
|
||||
- Console.log / structured log statements around the suspicious code path
|
||||
- Log the actual values flowing through: inputs, outputs, intermediate state
|
||||
- Add verbose mode flags if the stack supports them
|
||||
- Keep instrumentation minimal — only what confirms or refutes the hypothesis
|
||||
|
||||
3. Restart the stack using the configured script (if set):
|
||||
\${stack_script:-"# No stack_script configured — restart manually or connect to staging"}
|
||||
|
||||
4. Re-run the reproduction steps from the reproduce-agent findings.
|
||||
|
||||
5. Observe and capture new output:
|
||||
- Paste relevant log lines into your working notes
|
||||
- Note whether the observed values match or contradict the hypothesis
|
||||
|
||||
6. If the first instrumentation pass is inconclusive, iterate:
|
||||
- Narrow the scope to the next most suspicious boundary
|
||||
- Re-instrument, restart, re-run
|
||||
- Maximum 2-3 instrumentation rounds before declaring inconclusive
|
||||
|
||||
Do NOT push the debug branch. It will be deleted in the cleanup step.
|
||||
|
||||
### Step 4: Decompose root causes into backlog issues
|
||||
After tracing and instrumentation, articulate each distinct root cause.
|
||||
|
||||
For each root cause found:
|
||||
|
||||
1. Determine the relationship to other causes:
|
||||
- Layered (one causes another) → use Depends-on in the issue body
|
||||
- Independent (separate code paths fail independently) → use Related
|
||||
|
||||
2. Create a backlog issue for each root cause:
|
||||
curl -sf -X POST "\${FORGE_API}/issues" \\
|
||||
-H "Authorization: token \${FORGE_TOKEN}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"title": "fix: <specific description of root cause N>",
|
||||
"body": "## Root cause\\n<exact code path, file:line>\\n\\n## Fix suggestion\\n<recommended approach>\\n\\n## Context\\nDecomposed from #\${ISSUE_NUMBER} (cause N of M)\\n\\n## Dependencies\\n<#X if this depends on another cause being fixed first>",
|
||||
"labels": [{"name": "backlog"}]
|
||||
}'
|
||||
|
||||
3. Note the newly created issue numbers.
|
||||
|
||||
If only one root cause is found, still create a single backlog issue with
|
||||
the specific code location and fix suggestion.
|
||||
|
||||
If the investigation is inconclusive (no clear root cause found), skip this
|
||||
step and proceed directly to link-back with the inconclusive outcome.
|
||||
|
||||
### Step 5: Update original issue and relabel
|
||||
Post a summary comment on the original issue and update its labels.
|
||||
|
||||
#### If root causes were found (conclusive):
|
||||
|
||||
Post a comment:
|
||||
"## Triage findings
|
||||
|
||||
Found N root cause(s):
|
||||
- #X — <one-line description> (cause 1 of N)
|
||||
- #Y — <one-line description> (cause 2 of N, depends on #X)
|
||||
|
||||
Data flow traced: <layer where the bug originates>
|
||||
Instrumentation: <key log output that confirmed the cause>
|
||||
|
||||
Next step: backlog issues above will be implemented in dependency order."
|
||||
|
||||
Then swap labels:
|
||||
- Remove: in-triage
|
||||
- Add: in-progress
|
||||
|
||||
#### If investigation was inconclusive (turn budget exhausted):
|
||||
|
||||
Post a comment:
|
||||
"## Triage — inconclusive
|
||||
|
||||
Traced: <layers checked>
|
||||
Tried: <instrumentation attempts and what they showed>
|
||||
Hypothesis: <best guess at cause, if any>
|
||||
|
||||
No definitive root cause identified. Leaving in-triage for supervisor
|
||||
to handle as a stale triage session."
|
||||
|
||||
Do NOT relabel. Leave in-triage. The supervisor monitors stale triage
|
||||
sessions and will escalate or reassign.
|
||||
|
||||
### Step 6: Delete throwaway debug branch
|
||||
Always delete the debug branch, even if the investigation was inconclusive.
|
||||
|
||||
1. Switch back to the main branch:
|
||||
cd "\$PROJECT_REPO_ROOT"
|
||||
git checkout "\$PRIMARY_BRANCH"
|
||||
|
||||
2. Delete the local debug branch:
|
||||
git branch -D debug/triage-\${ISSUE_NUMBER}
|
||||
|
||||
3. Confirm no remote was pushed (if accidentally pushed, delete it too):
|
||||
git push origin --delete debug/triage-\${ISSUE_NUMBER} 2>/dev/null || true
|
||||
|
||||
4. Verify the worktree is clean:
|
||||
git status
|
||||
git worktree list
|
||||
|
||||
A clean repo is a prerequisite for the next dev-agent run. Never leave
|
||||
debug branches behind — they accumulate and pollute the branch list.
|
||||
|
||||
## Notes
|
||||
- The application is accessible at localhost (network_mode: host)
|
||||
- Budget: 70% tracing data flow, 30% instrumented re-runs
|
||||
- Timeout: \${FORMULA_TIMEOUT_MINUTES} minutes total (or until turn limit)
|
||||
- Stack lock is held for the full run
|
||||
- If stack_script is empty, connect to existing staging environment
|
||||
|
||||
Begin now.
|
||||
PROMPT
|
||||
)
|
||||
else
|
||||
# Reproduce-agent prompt: reproduce the bug and report findings
|
||||
CLAUDE_PROMPT=$(cat <<PROMPT
|
||||
You are the reproduce-agent. Your task is to reproduce the bug described in issue #${ISSUE_NUMBER} and report your findings.
|
||||
|
||||
## Issue title
|
||||
${ISSUE_TITLE}
|
||||
|
||||
## Issue body
|
||||
${ISSUE_BODY}
|
||||
|
||||
## Your task — PRIMARY GOAL FIRST
|
||||
|
||||
This agent has ONE primary job and ONE secondary, minor job. Follow this ORDER:
|
||||
|
||||
### PRIMARY: Can the bug be reproduced? (60% of your turns)
|
||||
|
||||
This is the EXIT GATE. Answer YES or NO before doing anything else.
|
||||
|
||||
1. Read the issue, understand the claimed behavior
|
||||
2. Navigate the app via Playwright, follow the reported steps
|
||||
3. Observe: does the symptom match the report?
|
||||
4. Take screenshots as evidence (save to: ${SCREENSHOT_PREFIX}-step-N.png)
|
||||
5. Conclude: **reproduced** or **cannot reproduce**
|
||||
|
||||
If **cannot reproduce** → Write OUTCOME=cannot-reproduce, write findings, DONE. EXIT.
|
||||
If **inconclusive** (timeout, env issues, app not reachable) → Write OUTCOME=needs-triage with reason, write findings, DONE. EXIT.
|
||||
If **reproduced** → Continue to secondary check.
|
||||
|
||||
### SECONDARY (minor): Is the cause obvious? (40% of your turns, only if reproduced)
|
||||
|
||||
Only after reproduction is confirmed. Quick check only — do not go deep.
|
||||
|
||||
1. Check container logs: docker compose -f ${PROJECT_REPO_ROOT}/docker-compose.yml logs --tail=200
|
||||
Look for: stack traces, error messages, wrong addresses, missing config, parse errors
|
||||
2. Check browser console output captured during reproduction
|
||||
3. If the cause JUMPS OUT (clear error, obvious misconfiguration) → note it
|
||||
|
||||
If **obvious cause** → Write OUTCOME=reproduced and ROOT_CAUSE=<one-line summary>
|
||||
If **not obvious** → Write OUTCOME=reproduced (no ROOT_CAUSE line)
|
||||
|
||||
## Output files
|
||||
|
||||
1. **Findings report** — Write to: /tmp/reproduce-findings-${ISSUE_NUMBER}.md
|
||||
Include:
|
||||
- Steps you followed
|
||||
- What you observed (screenshots referenced by path)
|
||||
- Log excerpts (truncated to relevant lines)
|
||||
- OUTCOME line: OUTCOME=reproduced OR OUTCOME=cannot-reproduce OR OUTCOME=needs-triage
|
||||
- ROOT_CAUSE line (ONLY if cause is obvious): ROOT_CAUSE=<one-line summary>
|
||||
|
||||
2. **Outcome file** — Write to: /tmp/reproduce-outcome-${ISSUE_NUMBER}.txt
|
||||
Write ONLY the outcome word: reproduced OR cannot-reproduce OR needs-triage
|
||||
|
||||
## Notes
|
||||
- The application is accessible at localhost (network_mode: host)
|
||||
- Take screenshots liberally — they are evidence
|
||||
- If the app is not running or not reachable, write outcome: cannot-reproduce with reason "stack not reachable"
|
||||
- Timeout: ${FORMULA_TIMEOUT_MINUTES} minutes total
|
||||
- EXIT gates are enforced — do not continue to secondary check if primary result is NO or inconclusive
|
||||
|
||||
Begin now.
|
||||
PROMPT
|
||||
)
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Run Claude with Playwright MCP
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "$AGENT_TYPE" = "triage" ]; then
|
||||
log "Starting triage-agent session (timeout: ${FORMULA_TIMEOUT_MINUTES}m)..."
|
||||
else
|
||||
log "Starting Claude reproduction session (timeout: ${FORMULA_TIMEOUT_MINUTES}m)..."
|
||||
fi
|
||||
|
||||
CLAUDE_EXIT=0
|
||||
timeout "$(( FORMULA_TIMEOUT_MINUTES * 60 ))" \
|
||||
claude -p "$CLAUDE_PROMPT" \
|
||||
--mcp-server playwright \
|
||||
--output-format text \
|
||||
--max-turns 40 \
|
||||
> "/tmp/reproduce-claude-output-${ISSUE_NUMBER}.txt" 2>&1 || CLAUDE_EXIT=$?
|
||||
|
||||
if [ $CLAUDE_EXIT -eq 124 ]; then
|
||||
log "WARNING: Claude session timed out after ${FORMULA_TIMEOUT_MINUTES}m"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Triage post-processing: enforce backlog label on created issues
|
||||
# ---------------------------------------------------------------------------
|
||||
# The triage agent may create sub-issues for root causes. Ensure they have
|
||||
# the backlog label so dev-agent picks them up. Parse Claude output for
|
||||
# newly created issue numbers and add the backlog label.
|
||||
if [ "$AGENT_TYPE" = "triage" ]; then
|
||||
log "Triage post-processing: checking for created issues to label..."
|
||||
|
||||
# Extract issue numbers from Claude output that were created during triage.
|
||||
# Match unambiguous creation patterns: "Created issue #123", "Created #123",
|
||||
# or "harb#123". Do NOT match bare #123 which would capture references in
|
||||
# the triage summary (e.g., "Decomposed from #5", "cause 1 of 2", etc.).
|
||||
CREATED_ISSUES=$(grep -oE '(Created|created) issue #[0-9]+|(Created|created) #[0-9]+|harb#[0-9]+' \
|
||||
"/tmp/reproduce-claude-output-${ISSUE_NUMBER}.txt" 2>/dev/null | \
|
||||
grep -oE '[0-9]+' | sort -u | head -10)
|
||||
|
||||
if [ -n "$CREATED_ISSUES" ]; then
|
||||
# Get backlog label ID
|
||||
BACKLOG_ID=$(_label_id "backlog" "#fef2c0")
|
||||
|
||||
if [ -z "$BACKLOG_ID" ]; then
|
||||
log "WARNING: could not get backlog label ID — skipping label enforcement"
|
||||
else
|
||||
for issue_num in $CREATED_ISSUES; do
|
||||
_add_label "$issue_num" "$BACKLOG_ID"
|
||||
log "Added backlog label to created issue #${issue_num}"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read outcome
|
||||
# ---------------------------------------------------------------------------
|
||||
OUTCOME="needs-triage"
|
||||
OUTCOME_FILE=""
|
||||
OUTCOME_FOUND=false
|
||||
|
||||
# Check reproduce-agent outcome file first
|
||||
if [ -f "/tmp/reproduce-outcome-${ISSUE_NUMBER}.txt" ]; then
|
||||
OUTCOME_FILE="/tmp/reproduce-outcome-${ISSUE_NUMBER}.txt"
|
||||
OUTCOME_FOUND=true
|
||||
fi
|
||||
|
||||
# For triage agent, also check triage-specific outcome file
|
||||
if [ "$AGENT_TYPE" = "triage" ] && [ -f "/tmp/triage-outcome-${ISSUE_NUMBER}.txt" ]; then
|
||||
OUTCOME_FILE="/tmp/triage-outcome-${ISSUE_NUMBER}.txt"
|
||||
OUTCOME_FOUND=true
|
||||
fi
|
||||
|
||||
if [ "$OUTCOME_FOUND" = true ]; then
|
||||
_raw=$(tr -d '[:space:]' < "$OUTCOME_FILE" | tr '[:upper:]' '[:lower:]')
|
||||
case "$_raw" in
|
||||
reproduced|cannot-reproduce|needs-triage)
|
||||
OUTCOME="$_raw"
|
||||
;;
|
||||
*)
|
||||
log "WARNING: unexpected outcome '${_raw}' — defaulting to needs-triage"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# For triage agent, detect success by checking Claude output for:
|
||||
# 1. Triage findings comment indicating root causes were found
|
||||
# 2. Sub-issues created during triage
|
||||
if [ "$AGENT_TYPE" = "triage" ]; then
|
||||
CLAUDE_OUTPUT="/tmp/reproduce-claude-output-${ISSUE_NUMBER}.txt"
|
||||
|
||||
# Check for triage findings comment with root causes found
|
||||
if grep -q "## Triage findings" "$CLAUDE_OUTPUT" 2>/dev/null && \
|
||||
grep -q "Found [0-9]* root cause(s)" "$CLAUDE_OUTPUT" 2>/dev/null; then
|
||||
log "Triage success detected: findings comment with root causes found"
|
||||
OUTCOME="reproduced"
|
||||
OUTCOME_FOUND=true
|
||||
# Check for created sub-issues during triage
|
||||
elif grep -qE "(Created|created) issue #[0-9]+|(Created|created) #[0-9]+|harb#[0-9]+" "$CLAUDE_OUTPUT" 2>/dev/null; then
|
||||
log "Triage success detected: sub-issues created"
|
||||
OUTCOME="reproduced"
|
||||
OUTCOME_FOUND=true
|
||||
else
|
||||
log "WARNING: outcome file not found and no triage success indicators — defaulting to needs-triage"
|
||||
fi
|
||||
else
|
||||
log "WARNING: outcome file not found — defaulting to needs-triage"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Outcome: ${OUTCOME}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read findings
|
||||
# ---------------------------------------------------------------------------
|
||||
FINDINGS=""
|
||||
if [ -f "/tmp/reproduce-findings-${ISSUE_NUMBER}.md" ]; then
|
||||
FINDINGS=$(cat "/tmp/reproduce-findings-${ISSUE_NUMBER}.md")
|
||||
else
|
||||
if [ "$AGENT_TYPE" = "triage" ]; then
|
||||
FINDINGS="Triage-agent completed but did not write a findings report. Claude output:\n\`\`\`\n$(tail -100 "/tmp/reproduce-claude-output-${ISSUE_NUMBER}.txt" 2>/dev/null || echo '(no output)')\n\`\`\`"
|
||||
else
|
||||
FINDINGS="Reproduce-agent completed but did not write a findings report. Claude output:\n\`\`\`\n$(tail -100 "/tmp/reproduce-claude-output-${ISSUE_NUMBER}.txt" 2>/dev/null || echo '(no output)')\n\`\`\`"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collect screenshot paths for comment
|
||||
# ---------------------------------------------------------------------------
|
||||
SCREENSHOT_LIST=""
|
||||
if find "$(dirname "${SCREENSHOT_PREFIX}")" -name "$(basename "${SCREENSHOT_PREFIX}")-*.png" -maxdepth 1 2>/dev/null | grep -q .; then
|
||||
SCREENSHOT_LIST="\n\n**Screenshots taken:**\n"
|
||||
for f in "${SCREENSHOT_PREFIX}"-*.png; do
|
||||
SCREENSHOT_LIST="${SCREENSHOT_LIST}- \`$(basename "$f")\`\n"
|
||||
done
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Label helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
_label_id() {
|
||||
local name="$1" color="$2"
|
||||
local id
|
||||
id=$(curl -sf \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/labels" 2>/dev/null \
|
||||
| jq -r --arg n "$name" '.[] | select(.name == $n) | .id' 2>/dev/null || echo "")
|
||||
if [ -z "$id" ]; then
|
||||
id=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/labels" \
|
||||
-d "{\"name\":\"${name}\",\"color\":\"${color}\"}" 2>/dev/null \
|
||||
| jq -r '.id // empty' 2>/dev/null || echo "")
|
||||
fi
|
||||
echo "$id"
|
||||
}
|
||||
|
||||
_add_label() {
|
||||
local issue="$1" label_id="$2"
|
||||
[ -z "$label_id" ] && return 0
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/issues/${issue}/labels" \
|
||||
-d "{\"labels\":[${label_id}]}" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
_remove_label() {
|
||||
local issue="$1" label_id="$2"
|
||||
[ -z "$label_id" ] && return 0
|
||||
curl -sf -X DELETE \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues/${issue}/labels/${label_id}" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
_post_comment() {
|
||||
local issue="$1" body="$2"
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/issues/${issue}/comments" \
|
||||
-d "$(jq -nc --arg b "$body" '{body:$b}')" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply labels and post findings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Exit gate logic:
|
||||
# 1. Can I reproduce it? → NO → rejected/blocked → EXIT
|
||||
# → YES → continue
|
||||
# 2. Is the cause obvious? → YES → backlog issue for dev → EXIT
|
||||
# → NO → in-triage → EXIT
|
||||
#
|
||||
# Label combinations (on the ORIGINAL issue):
|
||||
# - Reproduced + obvious cause: reproduced (custom status) → backlog issue created
|
||||
# - Reproduced + cause unclear: in-triage → Triage-agent
|
||||
# - Cannot reproduce: rejected → Human review
|
||||
# - Inconclusive (timeout/error): blocked → Gardener/human
|
||||
#
|
||||
# The newly created fix issue (when cause is obvious) gets backlog label
|
||||
# so dev-poll will pick it up for implementation.
|
||||
|
||||
# Remove bug-report label (we are resolving it)
|
||||
BUG_REPORT_ID=$(_label_id "bug-report" "#e4e669")
|
||||
_remove_label "$ISSUE_NUMBER" "$BUG_REPORT_ID"
|
||||
|
||||
# Determine agent name for comments (based on AGENT_TYPE set at script start)
|
||||
if [ "$AGENT_TYPE" = "triage" ]; then
|
||||
AGENT_NAME="Triage-agent"
|
||||
else
|
||||
AGENT_NAME="Reproduce-agent"
|
||||
fi
|
||||
|
||||
# Determine outcome and apply appropriate labels
|
||||
LABEL_NAME=""
|
||||
LABEL_COLOR=""
|
||||
COMMENT_HEADER=""
|
||||
CREATE_BACKLOG_ISSUE=false
|
||||
|
||||
case "$OUTCOME" in
|
||||
reproduced)
|
||||
# Check if root cause is obvious (ROOT_CAUSE is set and non-trivial)
|
||||
ROOT_CAUSE=$(grep -m1 "^ROOT_CAUSE=" "/tmp/reproduce-findings-${ISSUE_NUMBER}.md" 2>/dev/null \
|
||||
| sed 's/^ROOT_CAUSE=//' || echo "")
|
||||
if [ -n "$ROOT_CAUSE" ] && [ "$ROOT_CAUSE" != "See findings on issue #${ISSUE_NUMBER}" ]; then
|
||||
# Obvious cause → add reproduced status label, create backlog issue for dev-agent
|
||||
LABEL_NAME="reproduced"
|
||||
LABEL_COLOR="#0075ca"
|
||||
COMMENT_HEADER="## ${AGENT_NAME}: **Reproduced with obvious cause** :white_check_mark: :zap:"
|
||||
CREATE_BACKLOG_ISSUE=true
|
||||
else
|
||||
# Cause unclear → in-triage → Triage-agent
|
||||
LABEL_NAME="in-triage"
|
||||
LABEL_COLOR="#d93f0b"
|
||||
COMMENT_HEADER="## ${AGENT_NAME}: **Reproduced, cause unclear** :white_check_mark: :mag:"
|
||||
fi
|
||||
;;
|
||||
|
||||
cannot-reproduce)
|
||||
# Cannot reproduce → rejected → Human review
|
||||
LABEL_NAME="rejected"
|
||||
LABEL_COLOR="#e4e669"
|
||||
COMMENT_HEADER="## ${AGENT_NAME}: **Cannot reproduce** :x:"
|
||||
;;
|
||||
|
||||
needs-triage)
|
||||
# Inconclusive (timeout, env issues) → blocked → Gardener/human
|
||||
LABEL_NAME="blocked"
|
||||
LABEL_COLOR="#e11d48"
|
||||
COMMENT_HEADER="## ${AGENT_NAME}: **Inconclusive, blocked** :construction:"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Apply the outcome label
|
||||
OUTCOME_LABEL_ID=$(_label_id "$LABEL_NAME" "$LABEL_COLOR")
|
||||
_add_label "$ISSUE_NUMBER" "$OUTCOME_LABEL_ID"
|
||||
log "Applied label '${LABEL_NAME}' to issue #${ISSUE_NUMBER}"
|
||||
|
||||
# If obvious cause, create backlog issue for dev-agent
|
||||
if [ "$CREATE_BACKLOG_ISSUE" = true ]; then
|
||||
BACKLOG_BODY="## Summary
|
||||
Bug reproduced from issue #${ISSUE_NUMBER}: ${ISSUE_TITLE}
|
||||
|
||||
Root cause (quick log analysis): ${ROOT_CAUSE}
|
||||
|
||||
## Dependencies
|
||||
- #${ISSUE_NUMBER}
|
||||
|
||||
## Affected files
|
||||
- (see findings on issue #${ISSUE_NUMBER})
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Root cause confirmed and fixed
|
||||
- [ ] Issue #${ISSUE_NUMBER} no longer reproducible"
|
||||
|
||||
log "Creating backlog issue for reproduced bug with obvious cause..."
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/issues" \
|
||||
-d "$(jq -nc \
|
||||
--arg t "fix: $(echo "$ISSUE_TITLE" | sed 's/^bug:/fix:/' | sed 's/^feat:/fix:/')" \
|
||||
--arg b "$BACKLOG_BODY" \
|
||||
'{title:$t, body:$b, labels:[{"name":"backlog"}]}' 2>/dev/null)" >/dev/null 2>&1 || \
|
||||
log "WARNING: failed to create backlog issue"
|
||||
fi
|
||||
|
||||
COMMENT_BODY="${COMMENT_HEADER}
|
||||
|
||||
${FINDINGS}${SCREENSHOT_LIST}
|
||||
|
||||
---
|
||||
*${AGENT_NAME} run at $(date -u '+%Y-%m-%d %H:%M:%S UTC') — project: ${PROJECT_NAME}*"
|
||||
|
||||
_post_comment "$ISSUE_NUMBER" "$COMMENT_BODY"
|
||||
log "Posted findings to issue #${ISSUE_NUMBER}"
|
||||
|
||||
log "${AGENT_NAME} done. Outcome: ${OUTCOME}"
|
||||
|
|
@ -114,3 +114,4 @@ When reviewing PRs or designing new agents, ask:
|
|||
| gardener | 1242 (agent 471 + poll 771) | Medium — backlog triage, duplicate detection, tech-debt scoring | Poll is heavy orchestration; agent is prompt-driven |
|
||||
| vault | 442 (4 scripts) | Medium — approval flow, human gate decisions | Intentionally bash-heavy (security gate should be deterministic) |
|
||||
| planner | 382 | Medium — AGENTS.md update, gap analysis | Tmux+formula (done, #232) |
|
||||
| action-agent | 192 | Light — formula execution | Close to target |
|
||||
|
|
|
|||
|
|
@ -92,9 +92,10 @@ PHASE:failed → label issue blocked, post diagnostic comment
|
|||
|
||||
### `idle_prompt` exit reason
|
||||
|
||||
The phase monitor can exit with `_MONITOR_LOOP_EXIT=idle_prompt`. This happens
|
||||
when Claude returns to the interactive prompt (`❯`) for **3 consecutive polls**
|
||||
without writing any phase signal to the phase file.
|
||||
`monitor_phase_loop` (in `lib/agent-session.sh`) can exit with
|
||||
`_MONITOR_LOOP_EXIT=idle_prompt`. This happens when Claude returns to the
|
||||
interactive prompt (`❯`) for **3 consecutive polls** without writing any phase
|
||||
signal to the phase file.
|
||||
|
||||
**Trigger conditions:**
|
||||
- The phase file is empty (no phase has ever been written), **and**
|
||||
|
|
@ -110,13 +111,14 @@ without writing any phase signal to the phase file.
|
|||
callback without the phase file actually containing that value.
|
||||
|
||||
**Agent requirements:**
|
||||
- **Callback:** Must handle `PHASE:failed` defensively — the session is already
|
||||
dead, so any tmux send-keys or session-dependent logic must be skipped or
|
||||
guarded.
|
||||
- **Callback (`_on_phase_change` / `formula_phase_callback`):** Must handle
|
||||
`PHASE:failed` defensively — the session is already dead, so any tmux
|
||||
send-keys or session-dependent logic must be skipped or guarded.
|
||||
- **Post-loop exit handler (`case $_MONITOR_LOOP_EXIT`):** Must include an
|
||||
`idle_prompt)` branch. Typical actions: log the event, clean up temp files,
|
||||
and (for agents that use escalation) write an escalation entry or notify via
|
||||
vault/forge. See `dev/dev-agent.sh` for reference implementations.
|
||||
vault/forge. See `dev/dev-agent.sh`, `action/action-agent.sh`, and
|
||||
`gardener/gardener-agent.sh` for reference implementations.
|
||||
|
||||
## Crash Recovery
|
||||
|
||||
|
|
|
|||
101
docs/VAULT.md
101
docs/VAULT.md
|
|
@ -1,101 +0,0 @@
|
|||
# Vault PR Workflow
|
||||
|
||||
This document describes the vault PR-based approval workflow for the ops repo.
|
||||
|
||||
## Overview
|
||||
|
||||
The vault system enables agents to request execution of privileged actions (deployments, token operations, etc.) through a PR-based approval process. This replaces the old vault directory structure with a more auditable, collaborative workflow.
|
||||
|
||||
## Branch Protection
|
||||
|
||||
The `main` branch on the ops repo (`johba/disinto-ops`) is protected via Forgejo branch protection to enforce:
|
||||
|
||||
- **Require 1 approval before merge** — All vault PRs must have at least one approval from an admin user
|
||||
- **Admin-only merge** — Only users with admin role can merge vault PRs (regular collaborators and bot accounts cannot)
|
||||
- **Block direct pushes** — All changes to `main` must go through PRs
|
||||
|
||||
### Protection Rules
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| `enable_push` | `false` |
|
||||
| `enable_force_push` | `false` |
|
||||
| `enable_merge_commit` | `true` |
|
||||
| `required_approvals` | `1` |
|
||||
| `admin_enforced` | `true` |
|
||||
|
||||
## Vault PR Lifecycle
|
||||
|
||||
1. **Request** — Agent calls `lib/vault.sh:vault_request()` with action TOML content
|
||||
2. **Validation** — TOML is validated against the schema in `vault/vault-env.sh`
|
||||
3. **PR Creation** — A PR is created on `disinto-ops` with:
|
||||
- Branch: `vault/<action-id>`
|
||||
- Title: `vault: <action-id>`
|
||||
- Labels: `vault`, `pending-approval`
|
||||
- File: `vault/actions/<action-id>.toml`
|
||||
- **Auto-merge enabled** — Forgejo will auto-merge after approval
|
||||
4. **Approval** — Admin user reviews and approves the PR
|
||||
5. **Auto-merge** — Forgejo automatically merges the PR once required approvals are met
|
||||
6. **Execution** — Dispatcher (issue #76) polls for merged vault PRs and executes them
|
||||
7. **Cleanup** — Executed vault items are moved to `fired/` (via PR)
|
||||
|
||||
## Bot Account Behavior
|
||||
|
||||
Bot accounts (dev-bot, review-bot, vault-bot, etc.) **cannot merge vault PRs** even if they have approval, due to the `admin_enforced` setting. This ensures:
|
||||
|
||||
- Only human admins can approve sensitive vault actions
|
||||
- Bot accounts can only create vault PRs, not execute them
|
||||
- Bot accounts cannot self-approve vault PRs (Forgejo prevents this automatically)
|
||||
- Manual admin review is always required for privileged operations
|
||||
|
||||
## Setup
|
||||
|
||||
To set up branch protection on the ops repo:
|
||||
|
||||
```bash
|
||||
# Source environment
|
||||
source lib/env.sh
|
||||
source lib/branch-protection.sh
|
||||
|
||||
# Set up protection
|
||||
setup_vault_branch_protection main
|
||||
|
||||
# Verify setup
|
||||
verify_branch_protection main
|
||||
```
|
||||
|
||||
Or use the CLI directly:
|
||||
|
||||
```bash
|
||||
export FORGE_TOKEN="<admin-token>"
|
||||
export FORGE_URL="https://codeberg.org"
|
||||
export FORGE_OPS_REPO="johba/disinto-ops"
|
||||
|
||||
# Set up protection
|
||||
bash lib/branch-protection.sh setup main
|
||||
|
||||
# Verify
|
||||
bash lib/branch-protection.sh verify main
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
To verify the protection is working:
|
||||
|
||||
1. **Bot cannot merge** — Attempt to merge a PR with a bot token (should fail with HTTP 405)
|
||||
2. **Admin can merge** — Attempt to merge with admin token (should succeed)
|
||||
3. **Direct push blocked** — Attempt `git push origin main` (should be rejected)
|
||||
|
||||
## Related Issues
|
||||
|
||||
- #73 — Vault redesign proposal
|
||||
- #74 — Vault action TOML schema
|
||||
- #75 — Vault PR creation helper (`lib/vault.sh`)
|
||||
- #76 — Dispatcher rewrite (poll for merged vault PRs)
|
||||
- #77 — Branch protection on ops repo (this issue)
|
||||
|
||||
## See Also
|
||||
|
||||
- [`lib/vault.sh`](../lib/vault.sh) — Vault PR creation helper
|
||||
- [`vault/vault-env.sh`](../vault/vault-env.sh) — TOML validation
|
||||
- [`lib/branch-protection.sh`](../lib/branch-protection.sh) — Branch protection helper
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
# formulas/dev.toml — Dev agent formula (issue implementation)
|
||||
#
|
||||
# Executed by dev/dev-agent.sh via tmux session with Claude.
|
||||
# dev-agent.sh is called by dev-poll.sh which finds the next ready issue
|
||||
# from the backlog (priority tier first, then plain backlog).
|
||||
#
|
||||
# Steps: preflight → implement → CI → review → merge → journal
|
||||
#
|
||||
# Key behaviors:
|
||||
# - Creates worktree for isolation
|
||||
# - Uses tmux session for persistent Claude interaction
|
||||
# - Phase-file signaling for orchestrator coordination
|
||||
# - Auto-retry on CI failures (max 3 attempts)
|
||||
# - Direct-merge for approved PRs (bypasses lock)
|
||||
|
||||
name = "dev"
|
||||
description = "Issue implementation: code, commit, push, address CI/review"
|
||||
version = 1
|
||||
model = "sonnet"
|
||||
|
||||
[context]
|
||||
files = ["AGENTS.md", "dev/AGENTS.md", "lib/env.sh", "lib/pr-lifecycle.sh", "lib/ci-helpers.sh"]
|
||||
|
||||
[[steps]]
|
||||
id = "preflight"
|
||||
title = "Review the issue and prepare implementation plan"
|
||||
description = """
|
||||
Read the issue body carefully. Understand:
|
||||
- What needs to be implemented
|
||||
- Any dependencies (check `## Dependencies` section)
|
||||
- Existing code that might be affected
|
||||
- Testing requirements
|
||||
|
||||
Then create a plan:
|
||||
1. What files need to be modified/created
|
||||
2. What tests need to be added
|
||||
3. Any documentation updates
|
||||
|
||||
Check the preflight metrics from supervisor if available:
|
||||
cat "$OPS_REPO_ROOT/journal/supervisor/$(date -u +%Y-%m-%d).md"
|
||||
|
||||
Note: Only proceed if all dependency issues are closed.
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
id = "implement"
|
||||
title = "Write code to implement the issue"
|
||||
description = """
|
||||
Implement the changes:
|
||||
|
||||
1. Create a new worktree:
|
||||
cd "$PROJECT_REPO_ROOT"
|
||||
git worktree add -b "dev/{agent}-{issue}" ../{agent}-{issue}
|
||||
|
||||
2. Make your changes to the codebase
|
||||
3. Add tests if applicable
|
||||
4. Update documentation if needed
|
||||
5. Commit with conventional commits:
|
||||
git add -A
|
||||
git commit -m "feat({issue}): {description}"
|
||||
|
||||
6. Push to forge:
|
||||
git push -u origin dev/{agent}-{issue}
|
||||
|
||||
7. Create PR via API or web interface
|
||||
- Title: feat({issue}): {description}
|
||||
- Body: Link to issue, describe changes
|
||||
- Labels: backlog, in-progress
|
||||
|
||||
Note: The worktree is preserved on crash for debugging.
|
||||
"""
|
||||
needs = ["preflight"]
|
||||
|
||||
[[steps]]
|
||||
id = "ci"
|
||||
title = "Wait for CI and address failures"
|
||||
description = """
|
||||
Monitor CI pipeline status via Woodpecker API:
|
||||
woodpecker_api /repos/${WOODPECKER_REPO_ID}/pipelines?branch=dev/{agent}-{issue}
|
||||
|
||||
Wait for CI to complete. If CI fails:
|
||||
|
||||
1. Read the CI logs to understand the failure
|
||||
2. Fix the issue
|
||||
3. Amend commit and force push
|
||||
4. Track CI attempts (max 3 retries)
|
||||
|
||||
CI fix tracker file:
|
||||
$DISINTO_LOG_DIR/dev/ci-fixes-{project}.json
|
||||
|
||||
On CI success, proceed to review.
|
||||
If CI exhausted (3 failures), escalate via PHASE:escalate.
|
||||
"""
|
||||
needs = ["implement"]
|
||||
|
||||
[[steps]]
|
||||
id = "review"
|
||||
title = "Address review feedback"
|
||||
description = """
|
||||
Check PR for review comments:
|
||||
curl -sf "${FORGE_API}/pulls/{pr-number}/comments"
|
||||
|
||||
For each comment:
|
||||
1. Understand the feedback
|
||||
2. Make changes to fix the issue
|
||||
3. Amend commit and force push
|
||||
4. Address the comment in the PR
|
||||
|
||||
If review approves, proceed to merge.
|
||||
If stuck or needs clarification, escalate via PHASE:escalate.
|
||||
"""
|
||||
needs = ["ci"]
|
||||
|
||||
[[steps]]
|
||||
id = "merge"
|
||||
title = "Merge the PR"
|
||||
description = """
|
||||
Check if PR is approved and CI is green:
|
||||
curl -sf "${FORGE_API}/pulls/{pr-number}"
|
||||
|
||||
If approved (merged=true or approved_by set):
|
||||
1. Merge the PR:
|
||||
curl -sf -X PUT "${FORGE_API}/pulls/{pr-number}/merge" \\
|
||||
-d '{"merge_method":"merge"}'
|
||||
|
||||
2. Mirror push to other remotes:
|
||||
mirror_push
|
||||
|
||||
3. Close the issue:
|
||||
curl -sf -X PATCH "${FORGE_API}/issues/{issue-number}" \\
|
||||
-d '{"state":"closed"}'
|
||||
|
||||
4. Delete the branch:
|
||||
git push origin --delete dev/{agent}-{issue}
|
||||
|
||||
If direct merge is blocked, note in journal and escalate.
|
||||
"""
|
||||
needs = ["review"]
|
||||
|
||||
[[steps]]
|
||||
id = "journal"
|
||||
title = "Write implementation journal"
|
||||
description = """
|
||||
Append a timestamped entry to the dev journal:
|
||||
|
||||
File path:
|
||||
$OPS_REPO_ROOT/journal/dev/$(date -u +%Y-%m-%d).md
|
||||
|
||||
If the file already exists (multiple PRs merged same day), append.
|
||||
If it does not exist, create it.
|
||||
|
||||
Format:
|
||||
## Dev implementation — {issue-number}
|
||||
Time: {timestamp}
|
||||
PR: {pr-number}
|
||||
Branch: dev/{agent}-{issue}
|
||||
|
||||
### Changes
|
||||
- {summary of changes}
|
||||
|
||||
### CI attempts: {n}
|
||||
### Review feedback: {n} comments addressed
|
||||
|
||||
### Lessons learned
|
||||
- {what you learned during implementation}
|
||||
|
||||
### Knowledge added
|
||||
If you discovered something new, add to knowledge:
|
||||
echo "### Lesson title
|
||||
Description." >> "${OPS_REPO_ROOT}/knowledge/{topic}.md"
|
||||
|
||||
After writing the journal, write the phase signal:
|
||||
echo 'PHASE:done' > "$PHASE_FILE"
|
||||
"""
|
||||
needs = ["merge"]
|
||||
|
|
@ -203,7 +203,7 @@ If all tiers clear, write the completion summary and signal done:
|
|||
echo "ACTION: grooming complete — 0 tech-debt remaining" >> "$RESULT_FILE"
|
||||
echo 'PHASE:done' > "$PHASE_FILE"
|
||||
|
||||
Vault items filed during this run appear as PRs on ops repo for human approval.
|
||||
Vault items filed during this run are picked up by vault-poll automatically.
|
||||
|
||||
On unrecoverable error (API unavailable, repeated failures):
|
||||
printf 'PHASE:failed\nReason: %s\n' 'describe what failed' > "$PHASE_FILE"
|
||||
|
|
|
|||
|
|
@ -1,245 +0,0 @@
|
|||
# formulas/release.toml — Release formula
|
||||
#
|
||||
# Defines the release workflow: tag Forgejo main, push to mirrors, build
|
||||
# and tag the agents Docker image, and restart agents.
|
||||
#
|
||||
# Triggered by vault PR approval (human creates vault PR, approves it, then
|
||||
# runner executes via `disinto run <id>`).
|
||||
#
|
||||
# Example vault item:
|
||||
# id = "release-v1.2.0"
|
||||
# formula = "release"
|
||||
# context = "Tag v1.2.0 — includes vault redesign, .profile system, architect agent"
|
||||
# secrets = []
|
||||
#
|
||||
# Steps: preflight → tag-main → push-mirrors → build-image → tag-image → restart-agents → commit-result
|
||||
|
||||
name = "release"
|
||||
description = "Tag Forgejo main, push to mirrors, build and tag agents image, restart agents"
|
||||
version = 1
|
||||
|
||||
[context]
|
||||
files = ["docker-compose.yml"]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Step 1: preflight
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[steps]]
|
||||
id = "preflight"
|
||||
title = "Validate release prerequisites"
|
||||
description = """
|
||||
Validate release prerequisites before proceeding.
|
||||
|
||||
1. Check that RELEASE_VERSION is set:
|
||||
- Must be in format: v1.2.3 (semver with 'v' prefix)
|
||||
- Validate with regex: ^v[0-9]+\\.[0-9]+\\.[0-9]+$
|
||||
- If not set, exit with error
|
||||
|
||||
2. Check that FORGE_TOKEN and FORGE_URL are set:
|
||||
- Required for Forgejo API calls
|
||||
|
||||
3. Check that DOCKER_HOST is accessible:
|
||||
- Test with: docker info
|
||||
- Required for image build
|
||||
|
||||
4. Check current branch is main:
|
||||
- git rev-parse --abbrev-ref HEAD
|
||||
- Must be 'main' or 'master'
|
||||
|
||||
5. Pull latest code:
|
||||
- git fetch origin "$PRIMARY_BRANCH"
|
||||
- git reset --hard origin/"$PRIMARY_BRANCH"
|
||||
- Ensure working directory is clean
|
||||
|
||||
6. Check if tag already exists locally:
|
||||
- git tag -l "$RELEASE_VERSION"
|
||||
- If exists, exit with error
|
||||
|
||||
7. Check if tag already exists on Forgejo:
|
||||
- curl -sf -H "Authorization: token $FORGE_TOKEN" \
|
||||
- "$FORGE_URL/api/v1/repos/$FORGE_REPO/git/tags/$RELEASE_VERSION"
|
||||
- If exists, exit with error
|
||||
|
||||
8. Export RELEASE_VERSION for subsequent steps:
|
||||
- export RELEASE_VERSION (already set from vault action)
|
||||
"""
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Step 2: tag-main
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[steps]]
|
||||
id = "tag-main"
|
||||
title = "Create tag on Forgejo main via API"
|
||||
description = """
|
||||
Create the release tag on Forgejo main via the Forgejo API.
|
||||
|
||||
1. Get current HEAD SHA of main:
|
||||
- curl -sf -H "Authorization: token $FORGE_TOKEN" \
|
||||
- "$FORGE_URL/api/v1/repos/$FORGE_REPO/branches/$PRIMARY_BRANCH"
|
||||
- Parse sha field from response
|
||||
|
||||
2. Create tag via Forgejo API:
|
||||
- curl -sf -X POST \
|
||||
- -H "Authorization: token $FORGE_TOKEN" \
|
||||
- -H "Content-Type: application/json" \
|
||||
- "$FORGE_URL/api/v1/repos/$FORGE_REPO/tags" \
|
||||
- -d "{\"tag\":\"$RELEASE_VERSION\",\"target\":\"$HEAD_SHA\",\"message\":\"Release $RELEASE_VERSION\"}"
|
||||
- Parse response for success
|
||||
|
||||
3. Log the tag creation:
|
||||
- echo "Created tag $RELEASE_VERSION on Forgejo (SHA: $HEAD_SHA)"
|
||||
|
||||
4. Store HEAD SHA for later verification:
|
||||
- echo "$HEAD_SHA" > /tmp/release-head-sha
|
||||
"""
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Step 3: push-mirrors
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[steps]]
|
||||
id = "push-mirrors"
|
||||
title = "Push tag to mirrors (Codeberg, GitHub)"
|
||||
description = """
|
||||
Push the newly created tag to all configured mirrors.
|
||||
|
||||
1. Add mirror remotes if not already present:
|
||||
- Codeberg: git remote add codeberg git@codeberg.org:${FORGE_REPO_OWNER}/${PROJECT_NAME}.git
|
||||
- GitHub: git remote add github git@github.com:disinto/${PROJECT_NAME}.git
|
||||
- Check with: git remote -v
|
||||
|
||||
2. Push tag to Codeberg:
|
||||
- git push codeberg "$RELEASE_VERSION" --tags
|
||||
- Or push all tags: git push codeberg --tags
|
||||
|
||||
3. Push tag to GitHub:
|
||||
- git push github "$RELEASE_VERSION" --tags
|
||||
- Or push all tags: git push github --tags
|
||||
|
||||
4. Verify tags exist on mirrors:
|
||||
- curl -sf -H "Authorization: token $GITHUB_TOKEN" \
|
||||
- "https://api.github.com/repos/disinto/${PROJECT_NAME}/tags/$RELEASE_VERSION"
|
||||
- curl -sf -H "Authorization: token $FORGE_TOKEN" \
|
||||
- "$FORGE_URL/api/v1/repos/$FORGE_REPO/git/tags/$RELEASE_VERSION"
|
||||
|
||||
5. Log success:
|
||||
- echo "Tag $RELEASE_VERSION pushed to mirrors"
|
||||
"""
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Step 4: build-image
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[steps]]
|
||||
id = "build-image"
|
||||
title = "Build agents Docker image"
|
||||
description = """
|
||||
Build the new agents Docker image with the tagged code.
|
||||
|
||||
1. Build image without cache to ensure fresh build:
|
||||
- docker compose build --no-cache agents
|
||||
|
||||
2. Verify image was created:
|
||||
- docker images | grep disinto-agents
|
||||
- Check image exists and has recent timestamp
|
||||
|
||||
3. Store image ID for later:
|
||||
- docker images disinto-agents --format "{{.ID}}" > /tmp/release-image-id
|
||||
|
||||
4. Log build completion:
|
||||
- echo "Built disinto-agents image"
|
||||
"""
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Step 5: tag-image
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[steps]]
|
||||
id = "tag-image"
|
||||
title = "Tag Docker image with version"
|
||||
description = """
|
||||
Tag the newly built agents image with the release version.
|
||||
|
||||
1. Get the untagged image ID:
|
||||
- docker images disinto-agents --format "{{.ID}}" --no-trunc | head -1
|
||||
|
||||
2. Tag the image:
|
||||
- docker tag disinto-agents disinto-agents:$RELEASE_VERSION
|
||||
|
||||
3. Verify tag:
|
||||
- docker images disinto-agents
|
||||
|
||||
4. Log tag:
|
||||
- echo "Tagged disinto-agents:$RELEASE_VERSION"
|
||||
"""
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Step 6: restart-agents
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[steps]]
|
||||
id = "restart-agents"
|
||||
title = "Restart agent containers with new image"
|
||||
description = """
|
||||
Restart agent containers to use the new image.
|
||||
|
||||
1. Pull the new image (in case it was pushed somewhere):
|
||||
- docker compose pull agents
|
||||
|
||||
2. Stop and remove existing agent containers:
|
||||
- docker compose down agents agents-llama 2>/dev/null || true
|
||||
|
||||
3. Start agents with new image:
|
||||
- docker compose up -d agents agents-llama
|
||||
|
||||
4. Wait for containers to be healthy:
|
||||
- for i in {1..30}; do
|
||||
- if docker inspect --format='{{.State.Health.Status}}' agents | grep -q healthy; then
|
||||
- echo "Agents container healthy"; break
|
||||
- fi
|
||||
- sleep 5
|
||||
- done
|
||||
|
||||
5. Verify containers are running:
|
||||
- docker compose ps agents agents-llama
|
||||
|
||||
6. Log restart:
|
||||
- echo "Restarted agents containers"
|
||||
"""
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Step 7: commit-result
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[steps]]
|
||||
id = "commit-result"
|
||||
title = "Write release result"
|
||||
description = """
|
||||
Write the release result to a file for tracking.
|
||||
|
||||
1. Get the image ID:
|
||||
- IMAGE_ID=$(cat /tmp/release-image-id)
|
||||
|
||||
2. Create result file:
|
||||
- cat > /tmp/release-result.json <<EOF
|
||||
- {
|
||||
- "version": "$RELEASE_VERSION",
|
||||
- "image_id": "$IMAGE_ID",
|
||||
- "forgejo_tag_url": "$FORGE_URL/$FORGE_REPO/src/$RELEASE_VERSION",
|
||||
- "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
- "status": "success"
|
||||
- }
|
||||
- EOF
|
||||
|
||||
3. Copy result to data directory:
|
||||
- mkdir -p "$PROJECT_REPO_ROOT/release"
|
||||
- cp /tmp/release-result.json "$PROJECT_REPO_ROOT/release/$RELEASE_VERSION.json"
|
||||
|
||||
4. Log result:
|
||||
- cat /tmp/release-result.json
|
||||
|
||||
5. Clean up temp files:
|
||||
- rm -f /tmp/release-head-sha /tmp/release-image-id /tmp/release-result.json
|
||||
"""
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
# formulas/reproduce.toml — Reproduce-agent formula
|
||||
#
|
||||
# Declares the reproduce-agent's runtime parameters.
|
||||
# The dispatcher reads this to configure the sidecar container.
|
||||
#
|
||||
# stack_script: path (relative to PROJECT_REPO_ROOT) of the script used to
|
||||
# restart/rebuild the project stack before reproduction. Omit (or leave
|
||||
# blank) to connect to an existing staging environment instead.
|
||||
#
|
||||
# tools: MCP servers to pass to claude via --mcp-server flags.
|
||||
#
|
||||
# timeout_minutes: hard upper bound on the Claude session.
|
||||
#
|
||||
# Exit gate logic:
|
||||
# 1. Can I reproduce it? → NO → rejected/blocked → EXIT
|
||||
# → YES → continue
|
||||
# 2. Is the cause obvious? → YES → in-progress + backlog issue → EXIT
|
||||
# → NO → in-triage → EXIT
|
||||
#
|
||||
# Turn budget: 60% on step 1 (reproduction), 40% on step 2 (cause check).
|
||||
|
||||
name = "reproduce"
|
||||
description = "Primary: reproduce the bug. Secondary: check if cause is obvious. Exit gates enforced."
|
||||
version = 1
|
||||
|
||||
# Set stack_script to the restart command for local stacks.
|
||||
# Leave empty ("") to target an existing staging environment.
|
||||
stack_script = ""
|
||||
|
||||
tools = ["playwright"]
|
||||
timeout_minutes = 15
|
||||
|
|
@ -112,7 +112,7 @@ near-duplicate exists, REQUEST_CHANGES and reference the existing item.
|
|||
Agents must NEVER execute external actions directly. Any action that touches
|
||||
an external system (publish, deploy, post, push to external registry, API
|
||||
calls to third-party services) MUST go through vault dispatch — i.e., the
|
||||
agent files a vault item (`$OPS_REPO_ROOT/vault/pending/*.json`) and the runner
|
||||
agent files a vault item (`$OPS_REPO_ROOT/vault/pending/*.json`) and the vault-runner
|
||||
container executes it with injected secrets.
|
||||
|
||||
Scan the diff for these patterns:
|
||||
|
|
@ -128,7 +128,8 @@ Scan the diff for these patterns:
|
|||
|
||||
If ANY of these patterns appear in agent code (scripts in `dev/`, `action/`,
|
||||
`planner/`, `gardener/`, `supervisor/`, `predictor/`, `review/`, `formulas/`,
|
||||
`lib/`) WITHOUT routing through vault dispatch (file a vault PR on ops repo — see #73-#77), **REQUEST_CHANGES**.
|
||||
`lib/`) WITHOUT routing through vault dispatch (`$OPS_REPO_ROOT/vault/pending/`, `vault-fire.sh`,
|
||||
`vault-run-action.sh`), **REQUEST_CHANGES**.
|
||||
|
||||
Explain that external actions must use vault dispatch per AD-006. The agent
|
||||
should file a vault item instead of executing directly.
|
||||
|
|
@ -136,7 +137,7 @@ should file a vault item instead of executing directly.
|
|||
**Exceptions** (do NOT flag these):
|
||||
- Code inside `vault/` — the vault system itself is allowed to handle secrets
|
||||
- References in comments or documentation explaining the architecture
|
||||
- `bin/disinto` setup commands that manage `.env.vault.enc` and the `run` subcommand
|
||||
- `bin/disinto` setup commands that manage `.env.vault.enc`
|
||||
- Local operations (git push to forge, forge API calls with `FORGE_TOKEN`)
|
||||
|
||||
## 6. Re-review (if previous review is provided)
|
||||
|
|
|
|||
|
|
@ -1,304 +0,0 @@
|
|||
# formulas/run-architect.toml — Architect formula
|
||||
#
|
||||
# Executed by architect-run.sh via cron — strategic decomposition of vision
|
||||
# issues into development sprints.
|
||||
#
|
||||
# This formula orchestrates the architect agent's workflow:
|
||||
# Step 1: Preflight — validate prerequisites and identify target issue
|
||||
# Step 2: Research + pitch — analyze codebase and write sprint pitch
|
||||
# Step 3: Sprint PR creation with questions (issue #101)
|
||||
# Step 4: Answer parsing + sub-issue filing (issue #102)
|
||||
#
|
||||
# AGENTS.md maintenance is handled by the gardener (#246).
|
||||
|
||||
name = "run-architect"
|
||||
description = "Architect: strategic decomposition of vision into sprints"
|
||||
version = 1
|
||||
model = "opus"
|
||||
|
||||
[context]
|
||||
files = ["VISION.md", "AGENTS.md"]
|
||||
# Prerequisite tree loaded from ops repo (ops: prefix)
|
||||
# Sprints directory tracked in ops repo
|
||||
|
||||
[[steps]]
|
||||
id = "preflight"
|
||||
title = "Preflight: validate prerequisites and identify target vision issue"
|
||||
description = """
|
||||
This step performs preflight checks and identifies the most unblocking vision issue.
|
||||
|
||||
Actions:
|
||||
1. Pull latest code from both disinto repo and ops repo
|
||||
2. Read prerequisite tree from $OPS_REPO_ROOT/prerequisites.md
|
||||
3. Fetch open issues labeled 'vision' from Forgejo API
|
||||
4. Check for open architect PRs on ops repo (handled by #101/#102)
|
||||
5. If open architect PRs exist, handle accept/reject responses (see Capability B below)
|
||||
6. If no vision issues, signal PHASE:done
|
||||
|
||||
Skip conditions:
|
||||
- If no vision issues are found, signal PHASE:done
|
||||
|
||||
Output:
|
||||
- Sets ARCHITECT_TARGET_ISSUE to the issue number of the selected vision issue
|
||||
- Exports VISION_ISSUES as a JSON array of issue objects
|
||||
|
||||
## Capability B: Handle accept/reject on existing pitch PRs
|
||||
|
||||
When open architect PRs exist on the ops repo:
|
||||
|
||||
1. Fetch comments on each open architect PR via Forgejo API
|
||||
2. Look for human response:
|
||||
|
||||
**ACCEPT** (case insensitive): Human wants to proceed
|
||||
- Architect does deep research for design forks (same as #100 research but now identifying decision points)
|
||||
- Formulates multiple-choice questions (Q1, Q2, Q3...)
|
||||
- Updates the sprint spec file on the PR branch:
|
||||
- Adds `## Design forks` section with fork options
|
||||
- Adds `## Proposed sub-issues` section with concrete issues per fork path
|
||||
- Comments on the PR with the questions formatted as multiple choice
|
||||
- Signal PHASE:done (answer processing is #102)
|
||||
|
||||
**REJECT: <reason>** (case insensitive, reason after colon):
|
||||
- Journal the rejection reason via profile_write_journal (if .profile exists)
|
||||
— the architect learns what pitches fail
|
||||
- Close the PR via Forgejo API (do not merge — rejected pitches do not persist in sprints/)
|
||||
- Remove the branch via Forgejo API
|
||||
- Signal PHASE:done
|
||||
|
||||
**No response yet**: skip silently, signal PHASE:done
|
||||
|
||||
All git operations use the Forgejo API (create branch, write/update file, create PR,
|
||||
close PR, delete branch). No SSH.
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
id = "research_pitch"
|
||||
title = "Research + pitch: analyze codebase and write sprint pitch"
|
||||
description = """
|
||||
This step performs deep codebase research and writes a sprint pitch for the
|
||||
selected vision issue.
|
||||
|
||||
Actions:
|
||||
|
||||
1. Read the codebase deeply:
|
||||
- Read all files mentioned in the issue body
|
||||
- Search for existing interfaces that could be reused
|
||||
- Check what infrastructure already exists
|
||||
|
||||
2. Assess complexity and cost:
|
||||
- How many files/subsystems are touched?
|
||||
- What new infrastructure would need to be maintained after this sprint?
|
||||
- What are the risks (breaking changes, security implications, integration complexity)?
|
||||
- Is this mostly gluecode or greenfield?
|
||||
|
||||
3. Write sprint pitch to scratch file for PR creation step (#101):
|
||||
|
||||
# Sprint pitch: <name>
|
||||
|
||||
## Vision issues
|
||||
- #N — <title>
|
||||
|
||||
## What this enables
|
||||
<what the project can do after this sprint that it can't do now>
|
||||
|
||||
## What exists today
|
||||
<current state — infrastructure, interfaces, code that can be reused>
|
||||
|
||||
## Complexity
|
||||
<number of files, subsystems, estimated sub-issues>
|
||||
<gluecode vs greenfield ratio>
|
||||
|
||||
## Risks
|
||||
<what could go wrong, what breaks if this is done badly>
|
||||
|
||||
## Cost — new infra to maintain
|
||||
<what ongoing maintenance burden does this sprint add>
|
||||
<new services, cron jobs, formulas, agent roles>
|
||||
|
||||
## Recommendation
|
||||
<architect's assessment: worth it / defer / alternative approach>
|
||||
|
||||
IMPORTANT: Do NOT include design forks or questions yet. The pitch is a go/no-go
|
||||
decision for the human. Questions come only after acceptance.
|
||||
|
||||
Output:
|
||||
- Writes sprint pitch to $SCRATCH_FILE (/tmp/architect-{project}-scratch.md)
|
||||
- The pitch serves as input for sprint PR creation step (#101)
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
id = "sprint_pr_creation"
|
||||
title = "Sprint PR creation with questions (issue #101)"
|
||||
description = """
|
||||
This step creates a PR on the ops repo with the sprint proposal when no PR exists yet.
|
||||
|
||||
## Capability A: Create pitch PR (from research output)
|
||||
|
||||
If step 2 (research/pitch) produced a pitch and no PR exists yet:
|
||||
|
||||
1. Create branch `architect/<sprint-slug>` on ops repo via Forgejo API
|
||||
- Sprint slug: lowercase, hyphenated version of sprint name
|
||||
- Use Forgejo API: POST /repos/{owner}/{repo}/git/branches
|
||||
|
||||
2. Write sprint spec file to sprints/<sprint-slug>.md on the new branch:
|
||||
|
||||
# Sprint: <name>
|
||||
|
||||
## Vision issues
|
||||
- #N — <title>
|
||||
|
||||
## What this enables
|
||||
<what the project can do after this sprint that it can't do now>
|
||||
|
||||
## What exists today
|
||||
<current state — infrastructure, interfaces, code that can be reused>
|
||||
|
||||
## Complexity
|
||||
<number of files/subsystems, estimated sub-issues>
|
||||
<gluecode vs greenfield ratio>
|
||||
|
||||
## Risks
|
||||
<what could go wrong, what breaks if this is done badly>
|
||||
|
||||
## Cost — new infra to maintain
|
||||
<what ongoing maintenance burden does this sprint add>
|
||||
<new services, cron jobs, formulas, agent roles>
|
||||
|
||||
## Recommendation
|
||||
<architect's assessment: worth it / defer / alternative approach>
|
||||
|
||||
3. Create PR on ops repo via Forgejo API:
|
||||
- Title: `architect: <sprint summary>`
|
||||
- Body: pitch content (what it enables, complexity, risks, cost)
|
||||
- Base branch: primary branch (main/master)
|
||||
- Head branch: architect/<sprint-slug>
|
||||
- Footer: "Reply `ACCEPT` to proceed with design questions, or `REJECT: <reason>` to decline."
|
||||
|
||||
4. Signal PHASE:done
|
||||
|
||||
## Forgejo API Reference
|
||||
|
||||
All operations use the Forgejo API with `Authorization: token ${FORGE_TOKEN}` header.
|
||||
|
||||
### Create branch
|
||||
```
|
||||
POST /repos/{owner}/{repo}/branches
|
||||
Body: {"new_branch_name": "architect/<sprint-slug>", "old_branch_name": "main"}
|
||||
```
|
||||
|
||||
### Create/update file
|
||||
```
|
||||
PUT /repos/{owner}/{repo}/contents/<path>
|
||||
Body: {"message": "sprint: add <sprint-slug>.md", "content": "<base64-encoded-content>", "branch": "architect/<sprint-slug>"}
|
||||
```
|
||||
|
||||
### Create PR
|
||||
```
|
||||
POST /repos/{owner}/{repo}/pulls
|
||||
Body: {"title": "architect: <sprint summary>", "body": "<pitch-content>", "head": "architect/<sprint-slug>", "base": "main"}
|
||||
```
|
||||
|
||||
### Close PR
|
||||
```
|
||||
PATCH /repos/{owner}/{repo}/pulls/{index}
|
||||
Body: {"state": "closed"}
|
||||
```
|
||||
|
||||
### Delete branch
|
||||
```
|
||||
DELETE /repos/{owner}/{repo}/git/branches/<branch-name>
|
||||
```
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
id = "answer_parsing"
|
||||
title = "Answer parsing + sub-issue filing (issue #102)"
|
||||
description = """
|
||||
This step processes human answers to design questions and files sub-issues.
|
||||
|
||||
## Preflight: Detect PRs in question phase
|
||||
|
||||
An architect PR is in the question phase if ALL of the following are true:
|
||||
- PR is open
|
||||
- PR body or sprint spec file contains a `## Design forks` section (added by #101 after ACCEPT)
|
||||
- PR has question comments (Q1, Q2, Q3... format)
|
||||
|
||||
## Answer parsing
|
||||
|
||||
Human comments on the PR use this format:
|
||||
```
|
||||
Q1: A
|
||||
Q2: B
|
||||
Q3: A
|
||||
```
|
||||
|
||||
Parser matches lines starting with `Q` + digit(s) + `:` + space + letter A-D (case insensitive).
|
||||
Ignore other content in the comment.
|
||||
|
||||
## Processing paths
|
||||
|
||||
### All questions answered (every `### Q` heading has a matching `Q<N>: <letter>` comment)
|
||||
|
||||
1. Parse each answer (e.g. `Q1: A`, `Q2: C`)
|
||||
2. Read the sprint spec from the PR branch
|
||||
3. Generate final sub-issues based on answers:
|
||||
- Each sub-issue uses the appropriate issue template (bug/feature/refactor from `.codeberg/ISSUE_TEMPLATE/`)
|
||||
- Fill all template fields:
|
||||
- Problem/motivation (feature) or What's broken (bug/refactor)
|
||||
- Proposed solution (feature) or Approach (refactor) or Steps to reproduce (bug)
|
||||
- Affected files (max 3)
|
||||
- Acceptance criteria (max 5)
|
||||
- Dependencies
|
||||
- File via Forgejo API on the **disinto repo** (not ops repo)
|
||||
- Label as `backlog`
|
||||
4. Comment on PR: "Sprint filed: #N, #N, #N"
|
||||
5. Merge the PR (sprint spec with answers persists in `ops/sprints/`)
|
||||
|
||||
### Some questions answered, not all
|
||||
|
||||
1. Acknowledge answers received
|
||||
2. Comment listing remaining unanswered questions
|
||||
3. Signal PHASE:done (check again next poll)
|
||||
|
||||
### No answers yet (questions posted but human hasn't responded)
|
||||
|
||||
1. Skip — signal PHASE:done
|
||||
|
||||
## Forgejo API for filing issues on disinto repo
|
||||
|
||||
All operations use the Forgejo API with `Authorization: token ${FORGE_TOKEN}` header.
|
||||
|
||||
### Create issue
|
||||
```
|
||||
POST /repos/{owner}/{repo}/issues
|
||||
Body: {
|
||||
"title": "<issue title>",
|
||||
"body": "<issue body with template fields>",
|
||||
"labels": [123], // backlog label ID
|
||||
"assignees": ["architect-bot"]
|
||||
}
|
||||
```
|
||||
|
||||
### Close PR
|
||||
```
|
||||
PATCH /repos/{owner}/{repo}/pulls/{index}
|
||||
Body: {"state": "closed"}
|
||||
```
|
||||
|
||||
### Merge PR
|
||||
```
|
||||
POST /repos/{owner}/{repo}/pulls/{index}/merge
|
||||
Body: {"Do": "merge"}
|
||||
```
|
||||
|
||||
### Post comment on PR (via issues endpoint)
|
||||
```
|
||||
POST /repos/{owner}/{repo}/issues/{index}/comments
|
||||
Body: {"body": "<comment text>"}
|
||||
```
|
||||
|
||||
### Get label ID
|
||||
```
|
||||
GET /repos/{owner}/{repo}/labels
|
||||
```
|
||||
"""
|
||||
|
|
@ -1,15 +1,16 @@
|
|||
# formulas/run-gardener.toml — Gardener housekeeping formula
|
||||
#
|
||||
# Defines the gardener's complete run: grooming (Claude session via
|
||||
# gardener-run.sh) + AGENTS.md maintenance + final commit-and-pr.
|
||||
# gardener-run.sh) + blocked-review + AGENTS.md maintenance + final
|
||||
# commit-and-pr.
|
||||
#
|
||||
# Gardener has journaling via .profile (issue #97), so it learns from
|
||||
# past runs and improves over time.
|
||||
# No memory, no journal. The gardener does mechanical housekeeping
|
||||
# based on current state — it doesn't need to remember past runs.
|
||||
#
|
||||
# Steps: preflight -> grooming -> dust-bundling -> agents-update -> commit-and-pr
|
||||
# Steps: preflight → grooming → dust-bundling → blocked-review → stale-pr-recycle → agents-update → commit-and-pr
|
||||
|
||||
name = "run-gardener"
|
||||
description = "Mechanical housekeeping: grooming, dust bundling, docs update"
|
||||
description = "Mechanical housekeeping: grooming, blocked review, docs update"
|
||||
version = 1
|
||||
|
||||
[context]
|
||||
|
|
@ -76,63 +77,6 @@ Pre-checks (bash, zero tokens — detect problems before invoking Claude):
|
|||
6. Tech-debt promotion: list all tech-debt labeled issues — goal is to
|
||||
process them all (promote to backlog or classify as dust).
|
||||
|
||||
7. Bug-report detection: for each open unlabeled issue (no backlog, no
|
||||
bug-report, no in-progress, no blocked, no underspecified, no vision,
|
||||
no tech-debt), check whether it describes a user-facing bug with
|
||||
reproduction steps. Criteria — ALL must be true:
|
||||
a. Body describes broken behavior (something that should work but
|
||||
doesn't), NOT a feature request or enhancement
|
||||
b. Body contains steps to reproduce (numbered list, "steps to
|
||||
reproduce" heading, or clear sequence of actions that trigger the bug)
|
||||
c. Issue is not already labeled
|
||||
|
||||
If all criteria match, enrich the issue body and write the manifest actions:
|
||||
|
||||
Body enrichment (CRITICAL — turns raw reports into actionable investigation briefs):
|
||||
Before writing the add_label action, construct an enriched body by appending
|
||||
these sections to the original issue body:
|
||||
|
||||
a. ``## What was reported``
|
||||
One or two sentence summary of the user's claim. Distill the broken
|
||||
behavior concisely — what the user expected vs. what actually happened.
|
||||
|
||||
b. ``## Known context``
|
||||
What can be inferred from the codebase without running anything:
|
||||
- Which contracts/components/files are involved (use AGENTS.md layout
|
||||
and file paths mentioned in the issue or body)
|
||||
- What the expected behavior should be (from VISION.md, docs, code)
|
||||
- Any recent changes to involved components:
|
||||
git log --oneline -5 -- <paths>
|
||||
- Related issues or prior fixes (cross-reference by number if known)
|
||||
|
||||
c. ``## Reproduction plan``
|
||||
Concrete steps for a reproduce-agent or human. Be specific:
|
||||
- Which environment to use (e.g. "start fresh stack with
|
||||
\`./scripts/dev.sh restart --full\`")
|
||||
- Which transactions or actions to execute (with \`cast\` commands,
|
||||
API calls, or UI navigation steps where applicable)
|
||||
- What state to check after each step (contract reads, API queries,
|
||||
UI observations, log output)
|
||||
|
||||
d. ``## What needs verification``
|
||||
Checkboxes distinguishing known facts from unknowns:
|
||||
- ``- [ ]`` Does the reported behavior actually occur? (reproduce)
|
||||
- ``- [ ]`` Is <component X> behaving as expected? (check state)
|
||||
- ``- [ ]`` Is the data flow correct from <A> to <B>? (trace)
|
||||
Tailor these to the specific bug — three to five items covering the
|
||||
key unknowns a reproduce-agent must resolve.
|
||||
|
||||
e. Construct full new body = original body text + appended sections.
|
||||
Write an edit_body action BEFORE the add_label action:
|
||||
echo '{"action":"edit_body","issue":NNN,"body":"<full new body>"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
|
||||
|
||||
f. Write the add_label action:
|
||||
echo '{"action":"add_label","issue":NNN,"label":"bug-report"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
|
||||
echo "ACTION: labeled #NNN as bug-report — <reason>" >> "$RESULT_FILE"
|
||||
|
||||
Do NOT also add the backlog label — bug-report is a separate triage
|
||||
track that feeds into reproduction automation.
|
||||
|
||||
For each issue, choose ONE action and write to result file:
|
||||
|
||||
ACTION (substantial — promote, close duplicate, add acceptance criteria):
|
||||
|
|
@ -176,17 +120,15 @@ DUST (trivial — single-line edit, rename, comment, style, whitespace):
|
|||
of 3+ into one backlog issue.
|
||||
|
||||
VAULT (needs human decision or external resource):
|
||||
File a vault procurement item using vault_request():
|
||||
source "$(dirname "$0")/../lib/vault.sh"
|
||||
TOML_CONTENT="# Vault action: <action_id>
|
||||
context = \"<description of what decision/resource is needed>\"
|
||||
unblocks = [\"#NNN\"]
|
||||
|
||||
[execution]
|
||||
# Commands to run after approval
|
||||
"
|
||||
PR_NUM=$(vault_request "<action_id>" "$TOML_CONTENT")
|
||||
echo "VAULT: filed PR #${PR_NUM} for #NNN — <reason>" >> "$RESULT_FILE"
|
||||
File a vault procurement item at $OPS_REPO_ROOT/vault/pending/<id>.md:
|
||||
# <What decision or resource is needed>
|
||||
## What
|
||||
<description>
|
||||
## Why
|
||||
<which issue this unblocks>
|
||||
## Unblocks
|
||||
- #NNN — <title>
|
||||
Log: echo "VAULT: filed $OPS_REPO_ROOT/vault/pending/<id>.md for #NNN — <reason>" >> "$RESULT_FILE"
|
||||
|
||||
CLEAN (only if truly nothing to do):
|
||||
echo 'CLEAN' >> "$RESULT_FILE"
|
||||
|
|
@ -200,7 +142,25 @@ Sibling dependency rule (CRITICAL):
|
|||
NEVER add bidirectional ## Dependencies between siblings (creates deadlocks).
|
||||
Use ## Related for cross-references: "## Related\n- #NNN (sibling)"
|
||||
|
||||
6. Quality gate — backlog label enforcement:
|
||||
7. Architecture decision alignment check (AD check):
|
||||
For each open issue labeled 'backlog', check whether the issue
|
||||
contradicts any architecture decision listed in the
|
||||
## Architecture Decisions section of AGENTS.md.
|
||||
Read AGENTS.md and extract the AD table. For each backlog issue,
|
||||
compare the issue title and body against each AD. If an issue
|
||||
clearly violates an AD:
|
||||
a. Write a comment action to the manifest:
|
||||
echo '{"action":"comment","issue":NNN,"body":"Closing: violates AD-NNN (<decision summary>). See AGENTS.md § Architecture Decisions."}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
|
||||
b. Write a close action to the manifest:
|
||||
echo '{"action":"close","issue":NNN,"reason":"violates AD-NNN"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
|
||||
c. Log to the result file:
|
||||
echo "ACTION: closed #NNN — violates AD-NNN" >> "$RESULT_FILE"
|
||||
|
||||
Only close for clear, unambiguous violations. If the issue is
|
||||
borderline or could be interpreted as compatible, leave it open
|
||||
and file a VAULT item for human decision instead.
|
||||
|
||||
8. Quality gate — backlog label enforcement:
|
||||
For each open issue labeled 'backlog', verify it has the required
|
||||
sections for dev-agent pickup:
|
||||
a. Acceptance criteria — body must contain at least one checkbox
|
||||
|
|
@ -221,12 +181,28 @@ Sibling dependency rule (CRITICAL):
|
|||
Well-structured issues (both sections present) are left untouched —
|
||||
they are ready for dev-agent pickup.
|
||||
|
||||
9. Portfolio lifecycle — maintain ## Addressables and ## Observables in AGENTS.md:
|
||||
Read the current Addressables and Observables tables from AGENTS.md.
|
||||
|
||||
a. ADD: if a recently closed issue shipped a new deployment, listing,
|
||||
package, or external presence not yet in the table, add a row.
|
||||
b. PROMOTE: if an addressable now has measurement wired (an evidence
|
||||
process reads from it), move it to the Observables section.
|
||||
c. REMOVE: if an addressable was decommissioned (vision change
|
||||
invalidated it, service shut down), remove the row and log why.
|
||||
d. FLAG: if an addressable has been live > 2 weeks with Observable? = No
|
||||
and no evidence process is planned, add a comment to the result file:
|
||||
echo "ACTION: flagged addressable '<name>' — live >2 weeks, no observation path" >> "$RESULT_FILE"
|
||||
|
||||
Stage AGENTS.md if changed — the commit-and-pr step handles the actual commit.
|
||||
|
||||
Processing order:
|
||||
1. Handle PRIORITY_blockers_starving_factory first — promote or resolve
|
||||
2. Quality gate — strip backlog from issues missing acceptance criteria or affected files
|
||||
3. Bug-report detection — label qualifying issues before other classification
|
||||
2. AD alignment check — close backlog issues that violate architecture decisions
|
||||
3. Quality gate — strip backlog from issues missing acceptance criteria or affected files
|
||||
4. Process tech-debt issues by score (impact/effort)
|
||||
5. Classify remaining items as dust or route to vault
|
||||
6. Portfolio lifecycle — update addressables/observables tables
|
||||
|
||||
Do NOT bundle dust yourself — the dust-bundling step handles accumulation,
|
||||
dedup, TTL expiry, and bundling into backlog issues.
|
||||
|
|
@ -281,12 +257,126 @@ session, so changes there would be lost.
|
|||
|
||||
5. If no DUST items were emitted and no groups are ripe, skip this step.
|
||||
|
||||
CRITICAL: If this step fails, log the failure and move on.
|
||||
CRITICAL: If this step fails, log the failure and move on to blocked-review.
|
||||
"""
|
||||
needs = ["grooming"]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Step 4: agents-update — AGENTS.md watermark staleness + size enforcement
|
||||
# Step 4: blocked-review — triage blocked issues
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[steps]]
|
||||
id = "blocked-review"
|
||||
title = "Review issues labeled blocked"
|
||||
description = """
|
||||
Review all issues labeled 'blocked' and decide their fate.
|
||||
(See issue #352 for the blocked label convention.)
|
||||
|
||||
1. Fetch all blocked issues:
|
||||
curl -sf -H "Authorization: token $FORGE_TOKEN" \
|
||||
"$FORGE_API/issues?state=open&type=issues&labels=blocked&limit=50"
|
||||
|
||||
2. For each blocked issue, read the full body and comments:
|
||||
curl -sf -H "Authorization: token $FORGE_TOKEN" \
|
||||
"$FORGE_API/issues/<number>"
|
||||
curl -sf -H "Authorization: token $FORGE_TOKEN" \
|
||||
"$FORGE_API/issues/<number>/comments"
|
||||
|
||||
3. Check dependencies — extract issue numbers from ## Dependencies /
|
||||
## Depends on / ## Blocked by sections. For each dependency:
|
||||
curl -sf -H "Authorization: token $FORGE_TOKEN" \
|
||||
"$FORGE_API/issues/<dep_number>"
|
||||
Check if the dependency is now closed.
|
||||
|
||||
4. For each blocked issue, choose ONE action:
|
||||
|
||||
UNBLOCK — all dependencies are now closed or the blocking condition resolved:
|
||||
a. Write a remove_label action to the manifest:
|
||||
echo '{"action":"remove_label","issue":NNN,"label":"blocked"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
|
||||
b. Write a comment action to the manifest:
|
||||
echo '{"action":"comment","issue":NNN,"body":"Unblocked: <explanation of what resolved the blocker>"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
|
||||
|
||||
NEEDS HUMAN — blocking condition is ambiguous, requires architectural
|
||||
decision, or involves external factors:
|
||||
a. Write a comment action to the manifest:
|
||||
echo '{"action":"comment","issue":NNN,"body":"<diagnostic: what you found and what decision is needed>"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
|
||||
b. Leave the 'blocked' label in place
|
||||
|
||||
CLOSE — issue is stale (blocked 30+ days with no progress on blocker),
|
||||
the blocker is wontfix, or the issue is no longer relevant:
|
||||
a. Write a comment action to the manifest:
|
||||
echo '{"action":"comment","issue":NNN,"body":"Closing: <reason — stale blocker, no longer relevant, etc.>"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
|
||||
b. Write a close action to the manifest:
|
||||
echo '{"action":"close","issue":NNN,"reason":"<stale blocker / no longer relevant / etc.>"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
|
||||
|
||||
CRITICAL: If this step fails, log the failure and move on.
|
||||
"""
|
||||
needs = ["dust-bundling"]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Step 5: stale-pr-recycle — recycle stale failed PRs back to backlog
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[steps]]
|
||||
id = "stale-pr-recycle"
|
||||
title = "Recycle stale failed PRs back to backlog"
|
||||
description = """
|
||||
Detect open PRs where CI has failed and no work has happened in 24+ hours.
|
||||
These represent abandoned dev-agent attempts — recycle them so the pipeline
|
||||
can retry with a fresh session.
|
||||
|
||||
1. Fetch all open PRs:
|
||||
curl -sf -H "Authorization: token $FORGE_TOKEN" \
|
||||
"$FORGE_API/pulls?state=open&limit=50"
|
||||
|
||||
2. For each PR, check all four conditions before recycling:
|
||||
|
||||
a. CI failed — get the HEAD SHA from the PR's head.sha field, then:
|
||||
curl -sf -H "Authorization: token $FORGE_TOKEN" \
|
||||
"$FORGE_API/commits/<head_sha>/status"
|
||||
Only proceed if the combined state is "failure" or "error".
|
||||
Skip PRs with "success", "pending", or no CI status.
|
||||
|
||||
b. Last push > 24 hours ago — get the commit details:
|
||||
curl -sf -H "Authorization: token $FORGE_TOKEN" \
|
||||
"$FORGE_API/git/commits/<head_sha>"
|
||||
Parse the committer.date field. Only proceed if it is older than:
|
||||
$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
c. Linked issue exists — extract the issue number from the PR body.
|
||||
Look for "Fixes #NNN" or "ixes #NNN" patterns (case-insensitive).
|
||||
If no linked issue found, skip this PR (cannot reset labels).
|
||||
|
||||
d. No active tmux session — check:
|
||||
tmux has-session -t "dev-${PROJECT_NAME}-<issue_number>" 2>/dev/null
|
||||
If a session exists, someone may still be working — skip this PR.
|
||||
|
||||
3. For each PR that passes all checks (failed CI, 24+ hours stale,
|
||||
linked issue found, no active session):
|
||||
|
||||
a. Write a comment on the PR explaining the recycle:
|
||||
echo '{"action":"comment","issue":<pr_number>,"body":"Recycling stale CI failure for fresh attempt. Previous PR: #<pr_number>"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
|
||||
|
||||
b. Write a close_pr action:
|
||||
echo '{"action":"close_pr","pr":<pr_number>}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
|
||||
|
||||
c. Remove the in-progress label from the linked issue:
|
||||
echo '{"action":"remove_label","issue":<issue_number>,"label":"in-progress"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
|
||||
|
||||
d. Add the backlog label to the linked issue:
|
||||
echo '{"action":"add_label","issue":<issue_number>,"label":"backlog"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
|
||||
|
||||
e. Log to result file:
|
||||
echo "ACTION: recycled PR #<pr_number> (linked issue #<issue_number>) — stale CI failure" >> "$RESULT_FILE"
|
||||
|
||||
4. If no stale failed PRs found, skip this step.
|
||||
|
||||
CRITICAL: If this step fails, log the failure and move on to agents-update.
|
||||
"""
|
||||
needs = ["blocked-review"]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Step 6: agents-update — AGENTS.md watermark staleness + size enforcement
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[steps]]
|
||||
|
|
@ -407,10 +497,10 @@ needed. You wouldn't dump a 500-page wiki on a new hire's first morning.
|
|||
CRITICAL: If this step fails for any reason, log the failure and move on.
|
||||
Do NOT let an AGENTS.md failure prevent the commit-and-pr step.
|
||||
"""
|
||||
needs = ["dust-bundling"]
|
||||
needs = ["stale-pr-recycle"]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Step 5: commit-and-pr — single commit with all file changes
|
||||
# Step 7: commit-and-pr — single commit with all file changes
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[steps]]
|
||||
|
|
@ -464,14 +554,16 @@ executes them after the PR merges.
|
|||
PR_NUMBER=$(echo "$PR_RESPONSE" | jq -r '.number')
|
||||
h. Save PR number for orchestrator tracking:
|
||||
echo "$PR_NUMBER" > /tmp/gardener-pr-${PROJECT_NAME}.txt
|
||||
i. The orchestrator handles CI/review via pr_walk_to_merge.
|
||||
The gardener stays alive to inject CI results and review feedback
|
||||
as they come in, then executes the pending-actions manifest after merge.
|
||||
i. Signal the orchestrator to monitor CI:
|
||||
echo "PHASE:awaiting_ci" > "$PHASE_FILE"
|
||||
j. STOP and WAIT. Do NOT return to the primary branch.
|
||||
The orchestrator polls CI, injects results and review feedback.
|
||||
When you receive injected CI or review feedback, follow its
|
||||
instructions, then write PHASE:awaiting_ci and wait again.
|
||||
|
||||
4. If no file changes existed (step 2 found nothing):
|
||||
# Nothing to commit — the gardener has no work to do this run.
|
||||
exit 0
|
||||
echo "PHASE:done" > "$PHASE_FILE"
|
||||
|
||||
5. If PR creation fails, log the error and exit.
|
||||
5. If PR creation fails, log the error and write PHASE:failed.
|
||||
"""
|
||||
needs = ["agents-update"]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
# planner-run.sh creates a tmux session with Claude (opus) and injects
|
||||
# this formula as context, plus the graph report from build-graph.py.
|
||||
#
|
||||
# Steps: preflight → triage-and-plan → commit-ops-changes
|
||||
# Steps: preflight → triage-and-plan → journal-and-commit
|
||||
#
|
||||
# v4 changes from v3:
|
||||
# - Graph report (orphans, cycles, thin objectives, bottlenecks) replaces
|
||||
|
|
@ -13,8 +13,7 @@
|
|||
# - 3 steps instead of 6.
|
||||
#
|
||||
# AGENTS.md maintenance is handled by the gardener (#246).
|
||||
# All git writes (tree, memory) happen in one commit at the end.
|
||||
# Journal writing is delegated to generic profile_write_journal() function.
|
||||
# All git writes (tree, journal, memory) happen in one commit at the end.
|
||||
|
||||
name = "run-planner"
|
||||
description = "Planner v4: graph-driven planning with tea helpers"
|
||||
|
|
@ -152,10 +151,13 @@ From the updated tree + graph bottlenecks, identify the top 5 constraints.
|
|||
A constraint is an unresolved prerequisite blocking the most downstream objectives.
|
||||
Graph bottlenecks (high betweenness centrality) and thin objectives inform ranking.
|
||||
|
||||
HUMAN_BLOCKED handling (needs human decision or external resource):
|
||||
- File a vault procurement item instead of skipping. First check for duplicates
|
||||
across ALL vault directories (pending/, approved/, fired/) — if a file with the
|
||||
same slug already exists in any of them, do NOT create a new one.
|
||||
Stuck issue handling:
|
||||
- BOUNCED/LABEL_CHURN: do NOT re-promote. Dispatch groom-backlog formula instead:
|
||||
tea_file_issue "chore: break down #<N> — bounced <count>x" "<body>" "action"
|
||||
- HUMAN_BLOCKED (needs human decision or external resource): file a vault
|
||||
procurement item instead of skipping. First check for duplicates across ALL
|
||||
vault directories (pending/, approved/, fired/) — if a file with the same
|
||||
slug already exists in any of them, do NOT create a new one.
|
||||
Naming: $OPS_REPO_ROOT/vault/pending/<project>-<slug>.md (e.g. disinto-github-org.md).
|
||||
Write with this template:
|
||||
|
||||
|
|
@ -183,37 +185,10 @@ HUMAN_BLOCKED handling (needs human decision or external resource):
|
|||
Then mark the prerequisite in the tree as "blocked-on-vault ($OPS_REPO_ROOT/vault/pending/<id>.md)".
|
||||
Do NOT skip or mark as "awaiting human decision" — the vault owns the human interface.
|
||||
|
||||
Template-or-vision filing gate (for non-stuck constraints):
|
||||
1. Read issue templates from .codeberg/ISSUE_TEMPLATE/*.yaml:
|
||||
- bug.yaml: for broken/incorrect behavior (error in logs, failing test)
|
||||
- feature.yaml: for new capabilities (prerequisite doesn't exist)
|
||||
- refactor.yaml: for restructuring without behavior change
|
||||
|
||||
2. Attempt to fill template fields:
|
||||
- affected_files: list 3 or fewer specific files
|
||||
- acceptance_criteria: write concrete, checkable criteria (max 5)
|
||||
- proposed_solution/approach: is there one clear approach, or design forks?
|
||||
|
||||
3. Complexity test:
|
||||
- If work touches ONE subsystem (3 or fewer files) AND no design forks
|
||||
(only one reasonable approach) AND template fields fill confidently:
|
||||
→ File as `backlog` using matching template format
|
||||
- Otherwise → Label `vision` with short body:
|
||||
- Problem statement
|
||||
- Why it's vision-sized
|
||||
- Which objectives it blocks
|
||||
- Include "## Why vision" section explaining complexity
|
||||
|
||||
4. Template selection heuristic:
|
||||
- Bug template: planner identifies something broken (error in logs,
|
||||
incorrect behavior, failing test)
|
||||
- Feature template: new capability needed (prerequisite doesn't exist)
|
||||
- Refactor template: existing code needs restructuring without behavior change
|
||||
|
||||
5. Filing steps:
|
||||
- Check if issue already exists (match by #number in tree or title search)
|
||||
- If no issue, create with tea_file_issue using template format
|
||||
- If issue exists and is open, skip — no duplicates
|
||||
Filing gate (for non-stuck constraints):
|
||||
1. Check if issue already exists (match by #number in tree or title search)
|
||||
2. If no issue, create one with tea_file_issue using the template above
|
||||
3. If issue exists and is open, skip — no duplicates
|
||||
|
||||
Priority label sync:
|
||||
- Add priority to current top-5 constraint issues (if missing):
|
||||
|
|
@ -242,13 +217,50 @@ CRITICAL: If any part of this step fails, log the failure and continue.
|
|||
needs = ["preflight"]
|
||||
|
||||
[[steps]]
|
||||
id = "commit-ops-changes"
|
||||
title = "Write tree, memory, and journal; commit and push"
|
||||
id = "journal-and-commit"
|
||||
title = "Write tree, journal, optional memory; commit and PR"
|
||||
description = """
|
||||
### 1. Write prerequisite tree
|
||||
Write to: $OPS_REPO_ROOT/prerequisites.md
|
||||
|
||||
### 2. Memory update (every 5th run)
|
||||
### 2. Write journal entry
|
||||
Create/append to: $OPS_REPO_ROOT/journal/planner/$(date -u +%Y-%m-%d).md
|
||||
|
||||
Format:
|
||||
# Planner run — YYYY-MM-DD HH:MM UTC
|
||||
|
||||
## Predictions triaged
|
||||
- #NNN: ACTION — reasoning (or "No unreviewed predictions")
|
||||
|
||||
## Prerequisite tree updates
|
||||
- Resolved: <list> - Discovered: <list> - Proposed: <list>
|
||||
|
||||
## Top 5 constraints
|
||||
1. <prerequisite> — blocks N objectives — #NNN (existing|filed)
|
||||
|
||||
## Stuck issues detected
|
||||
- #NNN: BOUNCED (Nx) — dispatched groom-backlog as #MMM
|
||||
(or "No stuck issues detected")
|
||||
|
||||
## Vault items filed
|
||||
- $OPS_REPO_ROOT/vault/pending/<id>.md — <what> — blocks #NNN
|
||||
(or "No vault items filed")
|
||||
|
||||
## Issues created
|
||||
- #NNN: title — why (or "No new issues")
|
||||
|
||||
## Priority label changes
|
||||
- Added/removed priority: #NNN (or "No priority changes")
|
||||
|
||||
## Observations
|
||||
- Key patterns noticed this run
|
||||
|
||||
## Deferred
|
||||
- Items in tree beyond top 5, why not filed
|
||||
|
||||
Keep concise — 30-50 lines max.
|
||||
|
||||
### 3. Memory update (every 5th run)
|
||||
Count "# Planner run —" headers across all journal files.
|
||||
Check "<!-- summarized-through-run: N -->" in planner-memory.md.
|
||||
If (count - N) >= 5 or planner-memory.md missing, write to:
|
||||
|
|
@ -256,19 +268,15 @@ If (count - N) >= 5 or planner-memory.md missing, write to:
|
|||
Include: run counter marker, date, constraint focus, patterns, direction.
|
||||
Keep under 100 lines. Replace entire file.
|
||||
|
||||
### 3. Commit ops repo changes
|
||||
Commit the ops repo changes (prerequisites, memory, vault items):
|
||||
### 4. Commit ops repo changes
|
||||
Commit the ops repo changes (prerequisites, journal, memory, vault items):
|
||||
cd "$OPS_REPO_ROOT"
|
||||
git add prerequisites.md knowledge/planner-memory.md vault/pending/
|
||||
git add prerequisites.md journal/planner/ knowledge/planner-memory.md vault/pending/
|
||||
git add -u
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "chore: planner run $(date -u +%Y-%m-%d)"
|
||||
git push origin "$PRIMARY_BRANCH"
|
||||
fi
|
||||
cd "$PROJECT_REPO_ROOT"
|
||||
|
||||
### 4. Write journal entry (generic)
|
||||
The planner-run.sh wrapper will handle journal writing via profile_write_journal()
|
||||
after the formula completes. This step is informational only.
|
||||
"""
|
||||
needs = ["triage-and-plan"]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
# Trigger: action issue created by planner (gap analysis), dev-poll (post-merge
|
||||
# hook detecting site/ changes), or gardener (periodic SHA drift check).
|
||||
#
|
||||
# The dispatcher picks up the issue, executes these steps, posts results
|
||||
# The action-agent picks up the issue, executes these steps, posts results
|
||||
# as a comment, and closes the issue.
|
||||
|
||||
name = "run-publish-site"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
# the action and notifies the human for one-click copy-paste execution.
|
||||
#
|
||||
# Trigger: action issue created by planner or any formula.
|
||||
# The dispatcher picks up the issue, executes these steps, writes a draft
|
||||
# The action-agent picks up the issue, executes these steps, writes a draft
|
||||
# to vault/outreach/{platform}/drafts/, notifies the human via the forge,
|
||||
# and closes the issue.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# formulas/run-supervisor.toml — Supervisor formula (health monitoring + remediation)
|
||||
#
|
||||
# Executed by supervisor/supervisor-run.sh via cron (every 20 minutes).
|
||||
# supervisor-run.sh runs claude -p via agent-sdk.sh and injects
|
||||
# supervisor-run.sh creates a tmux session with Claude (sonnet) and injects
|
||||
# this formula with pre-collected metrics as context.
|
||||
#
|
||||
# Steps: preflight → health-assessment → decide-actions → report → journal
|
||||
|
|
@ -137,15 +137,14 @@ For each finding from the health assessment, decide and execute an action.
|
|||
|
||||
**P3 Stale PRs (CI done >20min, no push since):**
|
||||
Do NOT read dev-poll.sh, push branches, attempt merges, or investigate pipeline code.
|
||||
Instead, file a vault item for the dev-agent to pick up:
|
||||
Write $OPS_REPO_ROOT/vault/pending/stale-pr-${ISSUE_NUM}.md:
|
||||
# Stale PR: ${PR_TITLE}
|
||||
## What
|
||||
CI finished >20min ago but no git push has been made to the PR branch.
|
||||
## Why
|
||||
P3 — Factory degraded: PRs should be pushed within 20min of CI completion.
|
||||
## Unblocks
|
||||
- Factory health: dev-agent will push the branch and continue the workflow
|
||||
Instead, nudge the dev-agent via tmux injection if a session is alive:
|
||||
# Find the dev session for this issue
|
||||
SESSION=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep "dev-.*-${ISSUE_NUM}" | head -1)
|
||||
if [ -n "$SESSION" ]; then
|
||||
# Inject a nudge into the dev-agent session
|
||||
tmux send-keys -t "$SESSION" "# [supervisor] PR stale >20min — CI finished, please push or update" Enter
|
||||
fi
|
||||
If no active tmux session exists, note it in the journal for the next dev-poll cycle.
|
||||
Do NOT file vault items for stale PRs unless they remain stale for >3 consecutive runs.
|
||||
|
||||
### Cannot auto-fix → file vault item
|
||||
|
|
@ -160,7 +159,7 @@ human judgment, file a vault procurement item:
|
|||
<impact on factory health — reference the priority level>
|
||||
## Unblocks
|
||||
- Factory health: <what this resolves>
|
||||
Vault PR filed on ops repo — human approves via PR review.
|
||||
The vault-poll will notify the human and track the request.
|
||||
|
||||
Read the relevant best-practices file before taking action:
|
||||
cat "$OPS_REPO_ROOT/knowledge/memory.md" # P0
|
||||
|
|
@ -242,16 +241,7 @@ run-to-run context so future supervisor runs can detect trends
|
|||
IMPORTANT: Do NOT commit or push the journal — it is a local working file.
|
||||
The journal directory is committed to git periodically by other agents.
|
||||
|
||||
## Learning
|
||||
|
||||
If you discover something new during this run, append it to the relevant
|
||||
knowledge file in the ops repo:
|
||||
echo "### Lesson title
|
||||
Description of what you learned." >> "${OPS_REPO_ROOT}/knowledge/<file>.md"
|
||||
|
||||
Knowledge files: memory.md, disk.md, ci.md, forge.md, dev-agent.md,
|
||||
review-agent.md, git.md.
|
||||
|
||||
After writing the journal, the agent session completes automatically.
|
||||
After writing the journal, write the phase signal:
|
||||
echo 'PHASE:done' > "$PHASE_FILE"
|
||||
"""
|
||||
needs = ["report"]
|
||||
|
|
|
|||
|
|
@ -1,267 +0,0 @@
|
|||
# formulas/triage.toml — Triage-agent formula (generic template)
|
||||
#
|
||||
# This is the base template for triage investigations.
|
||||
# Project-specific formulas (e.g. formulas/triage-harb.toml) extend this by
|
||||
# overriding the fields in the [project] section and providing stack-specific
|
||||
# step descriptions.
|
||||
#
|
||||
# Triggered by: bug-report + in-triage label combination.
|
||||
# Set by the reproduce-agent when:
|
||||
# - Bug was confirmed (reproduced)
|
||||
# - Quick log analysis did not reveal an obvious root cause
|
||||
# - Reproduce-agent documented all steps taken and logs examined
|
||||
#
|
||||
# Steps:
|
||||
# 1. read-findings — parse issue comments for prior reproduce-agent evidence
|
||||
# 2. trace-data-flow — follow symptom through UI → API → backend → data store
|
||||
# 3. instrumentation — throwaway branch, add logging, restart, observe
|
||||
# 4. decompose — file backlog issues for each root cause
|
||||
# 5. link-back — update original issue, swap in-triage → in-progress
|
||||
# 6. cleanup — delete throwaway debug branch
|
||||
#
|
||||
# Best practices:
|
||||
# - Start from reproduce-agent findings; do not repeat their work
|
||||
# - Budget: 70% tracing data flow, 30% instrumented re-runs
|
||||
# - Multiple causes: check if layered (Depends-on) or independent (Related)
|
||||
# - Always delete the throwaway debug branch before finishing
|
||||
# - If inconclusive after full turn budget: leave in-triage, post what was
|
||||
# tried, do NOT relabel — supervisor handles stale triage sessions
|
||||
#
|
||||
# Project-specific formulas extend this template by defining:
|
||||
# - stack_script: how to start/stop the project stack
|
||||
# - [project].data_flow: layer names (e.g. "chain → indexer → GraphQL → UI")
|
||||
# - [project].api_endpoints: which APIs/services to inspect
|
||||
# - [project].stack_lock: stack lock configuration
|
||||
# - Per-step description overrides with project-specific commands
|
||||
#
|
||||
# No hard timeout — runs until Claude hits its turn limit.
|
||||
# Stack lock held for full run (triage is rare; blocking CI is acceptable).
|
||||
|
||||
name = "triage"
|
||||
description = "Deep root cause analysis: trace data flow, add debug instrumentation, decompose causes into backlog issues."
|
||||
version = 2
|
||||
|
||||
# Set stack_script to the restart command for local stacks.
|
||||
# Leave empty ("") to connect to an existing staging environment.
|
||||
stack_script = ""
|
||||
|
||||
tools = ["playwright"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project-specific extension fields.
|
||||
# Override these in formulas/triage-<project>.toml.
|
||||
# ---------------------------------------------------------------------------
|
||||
[project]
|
||||
# Human-readable layer names for the data-flow trace (generic default).
|
||||
# Example project override: "chain → indexer → GraphQL → UI"
|
||||
data_flow = "UI → API → backend → data store"
|
||||
|
||||
# Comma-separated list of API endpoints or services to inspect.
|
||||
# Example: "GraphQL /graphql, REST /api/v1, RPC ws://localhost:8545"
|
||||
api_endpoints = ""
|
||||
|
||||
# Stack lock configuration (leave empty for default behavior).
|
||||
# Example: "full" to hold a full stack lock during triage.
|
||||
stack_lock = ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
[[steps]]
|
||||
id = "read-findings"
|
||||
title = "Read reproduce-agent findings"
|
||||
description = """
|
||||
Before doing anything else, parse all prior evidence from the issue comments.
|
||||
|
||||
1. Fetch the issue body and all comments:
|
||||
curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues/${ISSUE_NUMBER}" | jq -r '.body'
|
||||
curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues/${ISSUE_NUMBER}/comments" | jq -r '.[].body'
|
||||
|
||||
2. Identify the reproduce-agent comment (look for sections like
|
||||
"Reproduction steps", "Logs examined", "What was tried").
|
||||
|
||||
3. Extract and note:
|
||||
- The exact symptom (error message, unexpected value, visual regression)
|
||||
- Steps that reliably trigger the bug
|
||||
- Log lines or API responses already captured
|
||||
- Any hypotheses the reproduce-agent already ruled out
|
||||
|
||||
Do NOT repeat work the reproduce-agent already did. Your job starts where
|
||||
theirs ended. If no reproduce-agent comment is found, note it and proceed
|
||||
with fresh investigation using the issue body only.
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
id = "trace-data-flow"
|
||||
title = "Trace data flow from symptom to source"
|
||||
description = """
|
||||
Systematically follow the symptom backwards through each layer of the stack.
|
||||
Spend ~70% of your total turn budget here before moving to instrumentation.
|
||||
|
||||
Generic layer traversal (adapt to the project's actual stack):
|
||||
UI → API → backend → data store
|
||||
|
||||
For each layer boundary:
|
||||
1. What does the upstream layer send?
|
||||
2. What does the downstream layer expect?
|
||||
3. Is there a mismatch? If yes — is this the root cause or a symptom?
|
||||
|
||||
Tracing checklist:
|
||||
a. Start at the layer closest to the visible symptom.
|
||||
b. Read the relevant source files — do not guess data shapes.
|
||||
c. Cross-reference API contracts: compare what the code sends vs what it
|
||||
should send according to schemas, type definitions, or documentation.
|
||||
d. Check recent git history on suspicious files:
|
||||
git log --oneline -20 -- <file>
|
||||
e. Search for related issues or TODOs in the code:
|
||||
grep -r "TODO\|FIXME\|HACK" -- <relevant directory>
|
||||
|
||||
Capture for each layer:
|
||||
- The data shape flowing in and out (field names, types, nullability)
|
||||
- Whether the layer's behavior matches its documented contract
|
||||
- Any discrepancy found
|
||||
|
||||
If a clear root cause becomes obvious during tracing, note it and continue
|
||||
checking whether additional causes exist downstream.
|
||||
"""
|
||||
needs = ["read-findings"]
|
||||
|
||||
[[steps]]
|
||||
id = "instrumentation"
|
||||
title = "Add debug instrumentation on a throwaway branch"
|
||||
description = """
|
||||
Use ~30% of your total turn budget here. Only instrument after tracing has
|
||||
identified the most likely failure points — do not instrument blindly.
|
||||
|
||||
1. Create a throwaway debug branch (NEVER commit this to main):
|
||||
cd "$PROJECT_REPO_ROOT"
|
||||
git checkout -b debug/triage-${ISSUE_NUMBER}
|
||||
|
||||
2. Add targeted logging at the layer boundaries identified during tracing:
|
||||
- Console.log / structured log statements around the suspicious code path
|
||||
- Log the actual values flowing through: inputs, outputs, intermediate state
|
||||
- Add verbose mode flags if the stack supports them
|
||||
- Keep instrumentation minimal — only what confirms or refutes the hypothesis
|
||||
|
||||
3. Restart the stack using the configured script (if set):
|
||||
${stack_script:-"# No stack_script configured — restart manually or connect to staging"}
|
||||
|
||||
4. Re-run the reproduction steps from the reproduce-agent findings.
|
||||
|
||||
5. Observe and capture new output:
|
||||
- Paste relevant log lines into your working notes
|
||||
- Note whether the observed values match or contradict the hypothesis
|
||||
|
||||
6. If the first instrumentation pass is inconclusive, iterate:
|
||||
- Narrow the scope to the next most suspicious boundary
|
||||
- Re-instrument, restart, re-run
|
||||
- Maximum 2-3 instrumentation rounds before declaring inconclusive
|
||||
|
||||
Do NOT push the debug branch. It will be deleted in the cleanup step.
|
||||
"""
|
||||
needs = ["trace-data-flow"]
|
||||
|
||||
[[steps]]
|
||||
id = "decompose"
|
||||
title = "Decompose root causes into backlog issues"
|
||||
description = """
|
||||
After tracing and instrumentation, articulate each distinct root cause.
|
||||
|
||||
For each root cause found:
|
||||
|
||||
1. Determine the relationship to other causes:
|
||||
- Layered (one causes another) → use Depends-on in the issue body
|
||||
- Independent (separate code paths fail independently) → use Related
|
||||
|
||||
2. Create a backlog issue for each root cause:
|
||||
curl -sf -X POST "${FORGE_API}/issues" \\
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"title": "fix: <specific description of root cause N>",
|
||||
"body": "## Root cause\\n<exact code path, file:line>\\n\\n## Fix suggestion\\n<recommended approach>\\n\\n## Context\\nDecomposed from #${ISSUE_NUMBER} (cause N of M)\\n\\n## Dependencies\\n<#X if this depends on another cause being fixed first>",
|
||||
"labels": [{"name": "backlog"}]
|
||||
}'
|
||||
|
||||
3. Note the newly created issue numbers.
|
||||
|
||||
If only one root cause is found, still create a single backlog issue with
|
||||
the specific code location and fix suggestion.
|
||||
|
||||
If the investigation is inconclusive (no clear root cause found), skip this
|
||||
step and proceed directly to link-back with the inconclusive outcome.
|
||||
"""
|
||||
needs = ["instrumentation"]
|
||||
|
||||
[[steps]]
|
||||
id = "link-back"
|
||||
title = "Update original issue and relabel"
|
||||
description = """
|
||||
Post a summary comment on the original issue and update its labels.
|
||||
|
||||
### If root causes were found (conclusive):
|
||||
|
||||
Post a comment:
|
||||
"## Triage findings
|
||||
|
||||
Found N root cause(s):
|
||||
- #X — <one-line description> (cause 1 of N)
|
||||
- #Y — <one-line description> (cause 2 of N, depends on #X)
|
||||
|
||||
Data flow traced: <layer where the bug originates>
|
||||
Instrumentation: <key log output that confirmed the cause>
|
||||
|
||||
Next step: backlog issues above will be implemented in dependency order."
|
||||
|
||||
Then swap labels:
|
||||
- Remove: in-triage
|
||||
- Add: in-progress
|
||||
|
||||
### If investigation was inconclusive (turn budget exhausted):
|
||||
|
||||
Post a comment:
|
||||
"## Triage — inconclusive
|
||||
|
||||
Traced: <layers checked>
|
||||
Tried: <instrumentation attempts and what they showed>
|
||||
Hypothesis: <best guess at cause, if any>
|
||||
|
||||
No definitive root cause identified. Leaving in-triage for supervisor
|
||||
to handle as a stale triage session."
|
||||
|
||||
Do NOT relabel. Leave in-triage. The supervisor monitors stale triage
|
||||
sessions and will escalate or reassign.
|
||||
|
||||
**CRITICAL: Write outcome file** — Always write the outcome to the outcome file:
|
||||
- If root causes found (conclusive): echo "reproduced" > /tmp/triage-outcome-${ISSUE_NUMBER}.txt
|
||||
- If inconclusive: echo "needs-triage" > /tmp/triage-outcome-${ISSUE_NUMBER}.txt
|
||||
"""
|
||||
needs = ["decompose"]
|
||||
|
||||
[[steps]]
|
||||
id = "cleanup"
|
||||
title = "Delete throwaway debug branch"
|
||||
description = """
|
||||
Always delete the debug branch, even if the investigation was inconclusive.
|
||||
|
||||
1. Switch back to the main branch:
|
||||
cd "$PROJECT_REPO_ROOT"
|
||||
git checkout "$PRIMARY_BRANCH"
|
||||
|
||||
2. Delete the local debug branch:
|
||||
git branch -D debug/triage-${ISSUE_NUMBER}
|
||||
|
||||
3. Confirm no remote was pushed (if accidentally pushed, delete it too):
|
||||
git push origin --delete debug/triage-${ISSUE_NUMBER} 2>/dev/null || true
|
||||
|
||||
4. Verify the worktree is clean:
|
||||
git status
|
||||
git worktree list
|
||||
|
||||
A clean repo is a prerequisite for the next dev-agent run. Never leave
|
||||
debug branches behind — they accumulate and pollute the branch list.
|
||||
"""
|
||||
needs = ["link-back"]
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
<!-- last-reviewed: ac2beac361503c8712ecfc72be0401b5968cce4e -->
|
||||
<!-- last-reviewed: 9b91c6a5bdd83513ca262dd468ea14db675971c1 -->
|
||||
# Gardener Agent
|
||||
|
||||
**Role**: Backlog grooming — detect duplicate issues, missing acceptance
|
||||
|
|
@ -22,8 +22,7 @@ directly from cron like the planner, predictor, and supervisor.
|
|||
`PHASE:awaiting_ci` — injects CI results and review feedback, re-signals
|
||||
`PHASE:awaiting_ci` after fixes, signals `PHASE:awaiting_review` on CI pass.
|
||||
Executes pending-actions manifest after PR merge.
|
||||
- `formulas/run-gardener.toml` — Execution spec: preflight, grooming, dust-bundling,
|
||||
agents-update, commit-and-pr
|
||||
- `formulas/run-gardener.toml` — Execution spec: preflight, grooming, dust-bundling, blocked-review, agents-update, commit-and-pr
|
||||
- `gardener/pending-actions.json` — Manifest of deferred repo actions (label changes,
|
||||
closures, comments, issue creation). Written during grooming steps, committed to the
|
||||
PR, reviewed alongside AGENTS.md changes, executed by gardener-run.sh after merge.
|
||||
|
|
@ -35,7 +34,7 @@ directly from cron like the planner, predictor, and supervisor.
|
|||
**Lifecycle**: gardener-run.sh (cron 0,6,12,18) → `check_active gardener` → lock + memory guard →
|
||||
load formula + context → create tmux session →
|
||||
Claude grooms backlog (writes proposed actions to manifest), bundles dust,
|
||||
updates AGENTS.md, commits manifest + docs to PR →
|
||||
reviews blocked issues, updates AGENTS.md, commits manifest + docs to PR →
|
||||
`PHASE:awaiting_ci` (stays alive) → CI pass → `PHASE:awaiting_review` →
|
||||
review feedback → address + re-signal → merge → gardener-run.sh executes
|
||||
manifest actions via API → `PHASE:done`. When blocked on external resources
|
||||
|
|
|
|||
50
gardener/PROMPT.md
Normal file
50
gardener/PROMPT.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Gardener Prompt — Dust vs Ore
|
||||
|
||||
> **Note:** This is human documentation. The actual LLM prompt is built
|
||||
> inline in `gardener-poll.sh` (with dynamic context injection). This file
|
||||
> documents the design rationale for reference.
|
||||
|
||||
## Rule
|
||||
|
||||
Don't promote trivial tech-debt individually. Each promotion costs a full
|
||||
factory cycle: CI + dev-agent + review + merge. Don't fill minecarts with
|
||||
dust — put ore inside.
|
||||
|
||||
## What is dust?
|
||||
|
||||
- Comment fix
|
||||
- Variable rename
|
||||
- Style-only change (whitespace, formatting)
|
||||
- Single-line edit
|
||||
- Trivial cleanup with no behavioral impact
|
||||
|
||||
## What is ore?
|
||||
|
||||
- Multi-file changes
|
||||
- Behavioral fixes
|
||||
- Architectural improvements
|
||||
- Security or correctness issues
|
||||
- Anything requiring design thought
|
||||
|
||||
## LLM output format
|
||||
|
||||
When a tech-debt issue is dust, the LLM outputs:
|
||||
|
||||
```
|
||||
DUST: {"issue": NNN, "group": "<file-or-subsystem>", "title": "...", "reason": "..."}
|
||||
```
|
||||
|
||||
The `group` field clusters related dust by file or subsystem (e.g.
|
||||
`"gardener"`, `"lib/env.sh"`, `"dev-poll"`).
|
||||
|
||||
## Bundling
|
||||
|
||||
The script collects dust items into `gardener/dust.jsonl`. When a group
|
||||
accumulates 3+ items, the script automatically:
|
||||
|
||||
1. Creates one bundled backlog issue referencing all source issues
|
||||
2. Closes the individual source issues with a cross-reference comment
|
||||
3. Removes bundled items from the staging file
|
||||
|
||||
This converts N trivial issues into 1 actionable issue, saving N-1 factory
|
||||
cycles.
|
||||
|
|
@ -1,18 +1,10 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# gardener-run.sh — Cron wrapper: gardener execution via SDK + formula
|
||||
# gardener-run.sh — Cron wrapper: gardener execution via Claude + formula
|
||||
#
|
||||
# Synchronous bash loop using claude -p (one-shot invocation).
|
||||
# No tmux sessions, no phase files — the bash script IS the state machine.
|
||||
#
|
||||
# Flow:
|
||||
# 1. Guards: cron lock, memory check
|
||||
# 2. Load formula (formulas/run-gardener.toml)
|
||||
# 3. Build context: AGENTS.md, scratch file, prompt footer
|
||||
# 4. agent_run(worktree, prompt) → Claude does maintenance, pushes if needed
|
||||
# 5. If pushed: pr_walk_to_merge() from lib/pr-lifecycle.sh
|
||||
# 6. Post-merge: execute pending actions manifest (gardener/pending-actions.json)
|
||||
# 7. Mirror push
|
||||
# Runs 4x/day (or on-demand). Guards against concurrent runs and low memory.
|
||||
# Creates a tmux session with Claude (sonnet) reading formulas/run-gardener.toml.
|
||||
# No action issues — the gardener is a nervous system component, not work (AD-001).
|
||||
#
|
||||
# Usage:
|
||||
# gardener-run.sh [projects/disinto.toml] # project config (default: disinto)
|
||||
|
|
@ -30,60 +22,55 @@ export PROJECT_TOML="${1:-$FACTORY_ROOT/projects/disinto.toml}"
|
|||
source "$FACTORY_ROOT/lib/env.sh"
|
||||
# Use gardener-bot's own Forgejo identity (#747)
|
||||
FORGE_TOKEN="${FORGE_GARDENER_TOKEN:-${FORGE_TOKEN}}"
|
||||
# shellcheck source=../lib/agent-session.sh
|
||||
source "$FACTORY_ROOT/lib/agent-session.sh"
|
||||
# shellcheck source=../lib/formula-session.sh
|
||||
source "$FACTORY_ROOT/lib/formula-session.sh"
|
||||
# shellcheck source=../lib/worktree.sh
|
||||
source "$FACTORY_ROOT/lib/worktree.sh"
|
||||
# shellcheck source=../lib/ci-helpers.sh
|
||||
source "$FACTORY_ROOT/lib/ci-helpers.sh"
|
||||
# shellcheck source=../lib/mirrors.sh
|
||||
source "$FACTORY_ROOT/lib/mirrors.sh"
|
||||
# shellcheck source=../lib/guard.sh
|
||||
source "$FACTORY_ROOT/lib/guard.sh"
|
||||
# shellcheck source=../lib/agent-sdk.sh
|
||||
source "$FACTORY_ROOT/lib/agent-sdk.sh"
|
||||
# shellcheck source=../lib/pr-lifecycle.sh
|
||||
source "$FACTORY_ROOT/lib/pr-lifecycle.sh"
|
||||
|
||||
LOG_FILE="${DISINTO_LOG_DIR}/gardener/gardener.log"
|
||||
# shellcheck disable=SC2034 # consumed by agent-sdk.sh
|
||||
LOGFILE="$LOG_FILE"
|
||||
# shellcheck disable=SC2034 # consumed by agent-sdk.sh
|
||||
SID_FILE="/tmp/gardener-session-${PROJECT_NAME}.sid"
|
||||
LOG_FILE="$SCRIPT_DIR/gardener.log"
|
||||
# shellcheck disable=SC2034 # consumed by run_formula_and_monitor
|
||||
SESSION_NAME="gardener-${PROJECT_NAME}"
|
||||
PHASE_FILE="/tmp/gardener-session-${PROJECT_NAME}.phase"
|
||||
|
||||
# shellcheck disable=SC2034 # read by monitor_phase_loop in lib/agent-session.sh
|
||||
PHASE_POLL_INTERVAL=15
|
||||
|
||||
SCRATCH_FILE="/tmp/gardener-${PROJECT_NAME}-scratch.md"
|
||||
RESULT_FILE="/tmp/gardener-result-${PROJECT_NAME}.txt"
|
||||
GARDENER_PR_FILE="/tmp/gardener-pr-${PROJECT_NAME}.txt"
|
||||
WORKTREE="/tmp/${PROJECT_NAME}-gardener-run"
|
||||
|
||||
# Override LOG_AGENT for consistent agent identification
|
||||
# shellcheck disable=SC2034 # consumed by agent-sdk.sh and env.sh log()
|
||||
LOG_AGENT="gardener"
|
||||
# Merge-through state (used by _gardener_on_phase_change callback)
|
||||
_GARDENER_PR=""
|
||||
_GARDENER_MERGE_START=0
|
||||
_GARDENER_MERGE_TIMEOUT=1800 # 30 min
|
||||
_GARDENER_CI_FIX_COUNT=0
|
||||
_GARDENER_REVIEW_ROUND=0
|
||||
_GARDENER_CRASH_COUNT=0
|
||||
|
||||
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; }
|
||||
|
||||
# ── Guards ────────────────────────────────────────────────────────────────
|
||||
check_active gardener
|
||||
acquire_cron_lock "/tmp/gardener-run.lock"
|
||||
memory_guard 2000
|
||||
check_memory 2000
|
||||
|
||||
log "--- Gardener run start ---"
|
||||
|
||||
# ── Resolve forge remote for git operations ─────────────────────────────
|
||||
resolve_forge_remote
|
||||
|
||||
# ── Resolve agent identity for .profile repo ────────────────────────────
|
||||
resolve_agent_identity || true
|
||||
|
||||
# ── Load formula + context ───────────────────────────────────────────────
|
||||
load_formula_or_profile "gardener" "$FACTORY_ROOT/formulas/run-gardener.toml" || exit 1
|
||||
load_formula "$FACTORY_ROOT/formulas/run-gardener.toml"
|
||||
build_context_block AGENTS.md
|
||||
|
||||
# ── Prepare .profile context (lessons injection) ─────────────────────────
|
||||
formula_prepare_profile_context
|
||||
|
||||
# ── Read scratch file (compaction survival) ───────────────────────────────
|
||||
SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
|
||||
SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
|
||||
|
||||
# ── Build prompt ─────────────────────────────────────────────────────────
|
||||
# ── Build prompt (manifest format reference for deferred actions) ─────────
|
||||
GARDENER_API_EXTRA="
|
||||
|
||||
## Pending-actions manifest (REQUIRED)
|
||||
|
|
@ -102,21 +89,34 @@ Supported actions:
|
|||
|
||||
The commit-and-pr step converts JSONL to JSON array. The orchestrator executes
|
||||
actions after the PR merges. Do NOT call mutation APIs directly during the run."
|
||||
build_prompt_footer "$GARDENER_API_EXTRA"
|
||||
|
||||
build_sdk_prompt_footer "$GARDENER_API_EXTRA"
|
||||
PROMPT_FOOTER="${PROMPT_FOOTER}## Completion protocol (REQUIRED)
|
||||
When the commit-and-pr step creates a PR, write the PR number and stop:
|
||||
# Extend phase protocol with merge-through instructions for compaction survival
|
||||
PROMPT_FOOTER="${PROMPT_FOOTER}
|
||||
|
||||
## Merge-through protocol (commit-and-pr step)
|
||||
After creating the PR, write the PR number and signal CI:
|
||||
echo \"\$PR_NUMBER\" > '${GARDENER_PR_FILE}'
|
||||
Then STOP. Do NOT write PHASE: signals — the orchestrator handles CI, review, and merge.
|
||||
If no file changes exist (empty commit-and-pr), just stop — no PR needed."
|
||||
echo 'PHASE:awaiting_ci' > '${PHASE_FILE}'
|
||||
Then STOP and WAIT for CI results.
|
||||
When 'CI passed' is injected:
|
||||
echo 'PHASE:awaiting_review' > '${PHASE_FILE}'
|
||||
Then STOP and WAIT.
|
||||
When 'CI failed' is injected:
|
||||
Fix, commit, push, then: echo 'PHASE:awaiting_ci' > '${PHASE_FILE}'
|
||||
When review feedback is injected:
|
||||
Address all feedback, commit, push, then: echo 'PHASE:awaiting_ci' > '${PHASE_FILE}'
|
||||
If no file changes in commit-and-pr:
|
||||
echo 'PHASE:done' > '${PHASE_FILE}'"
|
||||
|
||||
PROMPT="You are the issue gardener for ${FORGE_REPO}. Work through the formula below.
|
||||
# shellcheck disable=SC2034 # consumed by run_formula_and_monitor
|
||||
PROMPT="You are the issue gardener for ${FORGE_REPO}. Work through the formula below. Follow the phase protocol: if the commit-and-pr step creates a PR, write PHASE:awaiting_ci and wait for orchestrator CI/review/merge handling. If no file changes, write PHASE:done. The orchestrator will time you out if you return to the prompt without signalling.
|
||||
|
||||
You have full shell access and --dangerously-skip-permissions.
|
||||
Fix what you can. File vault items for what you cannot. Do NOT ask permission — act first, report after.
|
||||
|
||||
## Project context
|
||||
${CONTEXT_BLOCK}$(formula_lessons_block)
|
||||
${CONTEXT_BLOCK}
|
||||
${SCRATCH_CONTEXT:+${SCRATCH_CONTEXT}
|
||||
}
|
||||
## Result file
|
||||
|
|
@ -128,12 +128,14 @@ ${FORMULA_CONTENT}
|
|||
${SCRATCH_INSTRUCTION}
|
||||
${PROMPT_FOOTER}"
|
||||
|
||||
# ── Create worktree ──────────────────────────────────────────────────────
|
||||
formula_worktree_setup "$WORKTREE"
|
||||
# ── Phase callback for merge-through ─────────────────────────────────────
|
||||
# Handles CI polling, review injection, merge, and cleanup after PR creation.
|
||||
# Lighter than dev/phase-handler.sh — tailored for gardener doc-only PRs.
|
||||
|
||||
# ── Post-merge manifest execution ────────────────────────────────────────
|
||||
# ── Post-merge manifest execution ─────────────────────────────────────
|
||||
# Reads gardener/pending-actions.json and executes each action via API.
|
||||
# Failed actions are logged but do not block completion.
|
||||
# shellcheck disable=SC2317 # called indirectly via _gardener_merge
|
||||
_gardener_execute_manifest() {
|
||||
local manifest_file="$PROJECT_REPO_ROOT/gardener/pending-actions.json"
|
||||
if [ ! -f "$manifest_file" ]; then
|
||||
|
|
@ -158,21 +160,19 @@ _gardener_execute_manifest() {
|
|||
|
||||
case "$action" in
|
||||
add_label)
|
||||
local label label_id http_code resp
|
||||
local label label_id
|
||||
label=$(jq -r ".[$i].label" "$manifest_file")
|
||||
label_id=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/labels" | jq -r --arg n "$label" \
|
||||
'.[] | select(.name == $n) | .id') || true
|
||||
if [ -n "$label_id" ]; then
|
||||
resp=$(curl -sf -w "\n%{http_code}" -X POST -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
if curl -sf -X POST -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${FORGE_API}/issues/${issue}/labels" \
|
||||
-d "{\"labels\":[${label_id}]}" 2>/dev/null) || true
|
||||
http_code=$(echo "$resp" | tail -1)
|
||||
if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
|
||||
-d "{\"labels\":[${label_id}]}" >/dev/null 2>&1; then
|
||||
log "manifest: add_label '${label}' to #${issue}"
|
||||
else
|
||||
log "manifest: FAILED add_label '${label}' to #${issue}: HTTP ${http_code}"
|
||||
log "manifest: FAILED add_label '${label}' to #${issue}"
|
||||
fi
|
||||
else
|
||||
log "manifest: FAILED add_label — label '${label}' not found"
|
||||
|
|
@ -180,19 +180,17 @@ _gardener_execute_manifest() {
|
|||
;;
|
||||
|
||||
remove_label)
|
||||
local label label_id http_code resp
|
||||
local label label_id
|
||||
label=$(jq -r ".[$i].label" "$manifest_file")
|
||||
label_id=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/labels" | jq -r --arg n "$label" \
|
||||
'.[] | select(.name == $n) | .id') || true
|
||||
if [ -n "$label_id" ]; then
|
||||
resp=$(curl -sf -w "\n%{http_code}" -X DELETE -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues/${issue}/labels/${label_id}" 2>/dev/null) || true
|
||||
http_code=$(echo "$resp" | tail -1)
|
||||
if [ "$http_code" = "200" ] || [ "$http_code" = "204" ]; then
|
||||
if curl -sf -X DELETE -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues/${issue}/labels/${label_id}" >/dev/null 2>&1; then
|
||||
log "manifest: remove_label '${label}' from #${issue}"
|
||||
else
|
||||
log "manifest: FAILED remove_label '${label}' from #${issue}: HTTP ${http_code}"
|
||||
log "manifest: FAILED remove_label '${label}' from #${issue}"
|
||||
fi
|
||||
else
|
||||
log "manifest: FAILED remove_label — label '${label}' not found"
|
||||
|
|
@ -200,38 +198,34 @@ _gardener_execute_manifest() {
|
|||
;;
|
||||
|
||||
close)
|
||||
local reason http_code resp
|
||||
local reason
|
||||
reason=$(jq -r ".[$i].reason // empty" "$manifest_file")
|
||||
resp=$(curl -sf -w "\n%{http_code}" -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
if curl -sf -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${FORGE_API}/issues/${issue}" \
|
||||
-d '{"state":"closed"}' 2>/dev/null) || true
|
||||
http_code=$(echo "$resp" | tail -1)
|
||||
if [ "$http_code" = "200" ] || [ "$http_code" = "204" ]; then
|
||||
-d '{"state":"closed"}' >/dev/null 2>&1; then
|
||||
log "manifest: closed #${issue} (${reason})"
|
||||
else
|
||||
log "manifest: FAILED close #${issue}: HTTP ${http_code}"
|
||||
log "manifest: FAILED close #${issue}"
|
||||
fi
|
||||
;;
|
||||
|
||||
comment)
|
||||
local body escaped_body http_code resp
|
||||
local body escaped_body
|
||||
body=$(jq -r ".[$i].body" "$manifest_file")
|
||||
escaped_body=$(printf '%s' "$body" | jq -Rs '.')
|
||||
resp=$(curl -sf -w "\n%{http_code}" -X POST -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
if curl -sf -X POST -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${FORGE_API}/issues/${issue}/comments" \
|
||||
-d "{\"body\":${escaped_body}}" 2>/dev/null) || true
|
||||
http_code=$(echo "$resp" | tail -1)
|
||||
if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
|
||||
-d "{\"body\":${escaped_body}}" >/dev/null 2>&1; then
|
||||
log "manifest: commented on #${issue}"
|
||||
else
|
||||
log "manifest: FAILED comment on #${issue}: HTTP ${http_code}"
|
||||
log "manifest: FAILED comment on #${issue}"
|
||||
fi
|
||||
;;
|
||||
|
||||
create_issue)
|
||||
local title body labels escaped_title escaped_body label_ids http_code resp
|
||||
local title body labels escaped_title escaped_body label_ids
|
||||
title=$(jq -r ".[$i].title" "$manifest_file")
|
||||
body=$(jq -r ".[$i].body" "$manifest_file")
|
||||
labels=$(jq -r ".[$i].labels // [] | .[]" "$manifest_file")
|
||||
|
|
@ -251,46 +245,40 @@ _gardener_execute_manifest() {
|
|||
done <<< "$labels"
|
||||
[ -n "$ids_json" ] && label_ids="[${ids_json}]"
|
||||
fi
|
||||
resp=$(curl -sf -w "\n%{http_code}" -X POST -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
if curl -sf -X POST -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${FORGE_API}/issues" \
|
||||
-d "{\"title\":${escaped_title},\"body\":${escaped_body},\"labels\":${label_ids}}" 2>/dev/null) || true
|
||||
http_code=$(echo "$resp" | tail -1)
|
||||
if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
|
||||
-d "{\"title\":${escaped_title},\"body\":${escaped_body},\"labels\":${label_ids}}" >/dev/null 2>&1; then
|
||||
log "manifest: created issue '${title}'"
|
||||
else
|
||||
log "manifest: FAILED create_issue '${title}': HTTP ${http_code}"
|
||||
log "manifest: FAILED create_issue '${title}'"
|
||||
fi
|
||||
;;
|
||||
|
||||
edit_body)
|
||||
local body escaped_body http_code resp
|
||||
local body escaped_body
|
||||
body=$(jq -r ".[$i].body" "$manifest_file")
|
||||
escaped_body=$(printf '%s' "$body" | jq -Rs '.')
|
||||
resp=$(curl -sf -w "\n%{http_code}" -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
if curl -sf -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${FORGE_API}/issues/${issue}" \
|
||||
-d "{\"body\":${escaped_body}}" 2>/dev/null) || true
|
||||
http_code=$(echo "$resp" | tail -1)
|
||||
if [ "$http_code" = "200" ] || [ "$http_code" = "204" ]; then
|
||||
-d "{\"body\":${escaped_body}}" >/dev/null 2>&1; then
|
||||
log "manifest: edited body of #${issue}"
|
||||
else
|
||||
log "manifest: FAILED edit_body #${issue}: HTTP ${http_code}"
|
||||
log "manifest: FAILED edit_body #${issue}"
|
||||
fi
|
||||
;;
|
||||
|
||||
close_pr)
|
||||
local pr http_code resp
|
||||
local pr
|
||||
pr=$(jq -r ".[$i].pr" "$manifest_file")
|
||||
resp=$(curl -sf -w "\n%{http_code}" -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
if curl -sf -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${FORGE_API}/pulls/${pr}" \
|
||||
-d '{"state":"closed"}' 2>/dev/null) || true
|
||||
http_code=$(echo "$resp" | tail -1)
|
||||
if [ "$http_code" = "200" ] || [ "$http_code" = "204" ]; then
|
||||
-d '{"state":"closed"}' >/dev/null 2>&1; then
|
||||
log "manifest: closed PR #${pr}"
|
||||
else
|
||||
log "manifest: FAILED close_pr #${pr}: HTTP ${http_code}"
|
||||
log "manifest: FAILED close_pr #${pr}"
|
||||
fi
|
||||
;;
|
||||
|
||||
|
|
@ -305,53 +293,387 @@ _gardener_execute_manifest() {
|
|||
log "manifest: execution complete (${count} actions processed)"
|
||||
}
|
||||
|
||||
# ── Reset result file ────────────────────────────────────────────────────
|
||||
rm -f "$RESULT_FILE" "$GARDENER_PR_FILE"
|
||||
touch "$RESULT_FILE"
|
||||
# shellcheck disable=SC2317 # called indirectly by monitor_phase_loop
|
||||
_gardener_merge() {
|
||||
local merge_response merge_http_code
|
||||
merge_response=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${FORGE_API}/pulls/${_GARDENER_PR}/merge" \
|
||||
-d '{"Do":"merge","delete_branch_after_merge":true}') || true
|
||||
merge_http_code=$(echo "$merge_response" | tail -1)
|
||||
|
||||
# ── Run agent ─────────────────────────────────────────────────────────────
|
||||
export CLAUDE_MODEL="sonnet"
|
||||
|
||||
agent_run --worktree "$WORKTREE" "$PROMPT"
|
||||
log "agent_run complete"
|
||||
|
||||
# ── Detect PR ─────────────────────────────────────────────────────────────
|
||||
PR_NUMBER=""
|
||||
if [ -f "$GARDENER_PR_FILE" ]; then
|
||||
PR_NUMBER=$(tr -d '[:space:]' < "$GARDENER_PR_FILE")
|
||||
fi
|
||||
|
||||
# Fallback: search for open gardener PRs
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
PR_NUMBER=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/pulls?state=open&limit=10" | \
|
||||
jq -r '[.[] | select(.head.ref | startswith("chore/gardener-"))] | .[0].number // empty') || true
|
||||
fi
|
||||
|
||||
# ── Walk PR to merge ──────────────────────────────────────────────────────
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
log "walking PR #${PR_NUMBER} to merge"
|
||||
pr_walk_to_merge "$PR_NUMBER" "$_AGENT_SESSION_ID" "$WORKTREE" || true
|
||||
|
||||
if [ "$_PR_WALK_EXIT_REASON" = "merged" ]; then
|
||||
# Post-merge: pull primary, mirror push, execute manifest
|
||||
git -C "$PROJECT_REPO_ROOT" fetch "${FORGE_REMOTE}" "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
if [ "$merge_http_code" = "200" ] || [ "$merge_http_code" = "204" ]; then
|
||||
log "gardener PR #${_GARDENER_PR} merged"
|
||||
# Pull merged primary branch and push to mirrors
|
||||
git -C "$PROJECT_REPO_ROOT" fetch origin "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
git -C "$PROJECT_REPO_ROOT" checkout "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
git -C "$PROJECT_REPO_ROOT" pull --ff-only "${FORGE_REMOTE}" "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
git -C "$PROJECT_REPO_ROOT" pull --ff-only origin "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
mirror_push
|
||||
_gardener_execute_manifest
|
||||
rm -f "$SCRATCH_FILE"
|
||||
log "gardener PR #${PR_NUMBER} merged — manifest executed"
|
||||
else
|
||||
log "PR #${PR_NUMBER} not merged (reason: ${_PR_WALK_EXIT_REASON:-unknown})"
|
||||
printf 'PHASE:done\n' > "$PHASE_FILE"
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
log "no PR created — gardener run complete"
|
||||
|
||||
# Already merged (race)?
|
||||
if [ "$merge_http_code" = "405" ]; then
|
||||
local pr_merged
|
||||
pr_merged=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/pulls/${_GARDENER_PR}" | jq -r '.merged // false') || true
|
||||
if [ "$pr_merged" = "true" ]; then
|
||||
log "gardener PR #${_GARDENER_PR} already merged"
|
||||
# Pull merged primary branch and push to mirrors
|
||||
git -C "$PROJECT_REPO_ROOT" fetch origin "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
git -C "$PROJECT_REPO_ROOT" checkout "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
git -C "$PROJECT_REPO_ROOT" pull --ff-only origin "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
mirror_push
|
||||
_gardener_execute_manifest
|
||||
printf 'PHASE:done\n' > "$PHASE_FILE"
|
||||
return 0
|
||||
fi
|
||||
log "gardener merge blocked (HTTP 405)"
|
||||
printf 'PHASE:failed\nReason: gardener PR #%s merge blocked (HTTP 405)\n' \
|
||||
"$_GARDENER_PR" > "$PHASE_FILE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Other failure (likely conflicts) — tell Claude to rebase
|
||||
log "gardener merge failed (HTTP ${merge_http_code}) — requesting rebase"
|
||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
|
||||
"Merge failed for PR #${_GARDENER_PR} (likely conflicts). Rebase and push:
|
||||
git fetch origin ${PRIMARY_BRANCH} && git rebase origin/${PRIMARY_BRANCH}
|
||||
git push --force-with-lease origin HEAD
|
||||
echo \"PHASE:awaiting_ci\" > \"${PHASE_FILE}\"
|
||||
If rebase fails, write PHASE:failed with a reason."
|
||||
}
|
||||
|
||||
# shellcheck disable=SC2317 # called indirectly by monitor_phase_loop
|
||||
_gardener_timeout_cleanup() {
|
||||
log "gardener merge-through timed out (${_GARDENER_MERGE_TIMEOUT}s) — closing PR"
|
||||
if [ -n "$_GARDENER_PR" ]; then
|
||||
curl -sf -X PATCH \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${FORGE_API}/pulls/${_GARDENER_PR}" \
|
||||
-d '{"state":"closed"}' >/dev/null 2>&1 || true
|
||||
fi
|
||||
printf 'PHASE:failed\nReason: merge-through timeout (%ss)\n' \
|
||||
"$_GARDENER_MERGE_TIMEOUT" > "$PHASE_FILE"
|
||||
}
|
||||
|
||||
# shellcheck disable=SC2317 # called indirectly by monitor_phase_loop
|
||||
_gardener_handle_ci() {
|
||||
# Start merge-through timer on first CI phase
|
||||
if [ "$_GARDENER_MERGE_START" -eq 0 ]; then
|
||||
_GARDENER_MERGE_START=$(date +%s)
|
||||
fi
|
||||
|
||||
# Check merge-through timeout
|
||||
local elapsed
|
||||
elapsed=$(( $(date +%s) - _GARDENER_MERGE_START ))
|
||||
if [ "$elapsed" -ge "$_GARDENER_MERGE_TIMEOUT" ]; then
|
||||
_gardener_timeout_cleanup
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Discover PR number if unknown
|
||||
if [ -z "$_GARDENER_PR" ]; then
|
||||
if [ -f "$GARDENER_PR_FILE" ]; then
|
||||
_GARDENER_PR=$(tr -d '[:space:]' < "$GARDENER_PR_FILE")
|
||||
fi
|
||||
# Fallback: search for open gardener PRs
|
||||
if [ -z "$_GARDENER_PR" ]; then
|
||||
_GARDENER_PR=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/pulls?state=open&limit=10" | \
|
||||
jq -r '[.[] | select(.head.ref | startswith("chore/gardener-"))] | .[0].number // empty') || true
|
||||
fi
|
||||
if [ -z "$_GARDENER_PR" ]; then
|
||||
log "ERROR: cannot find gardener PR"
|
||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
|
||||
"ERROR: Could not find the gardener PR. Verify branch was pushed and PR created. Write the PR number to ${GARDENER_PR_FILE}, then write PHASE:awaiting_ci again."
|
||||
return 0
|
||||
fi
|
||||
log "tracking gardener PR #${_GARDENER_PR}"
|
||||
fi
|
||||
|
||||
# Skip CI for doc-only PRs
|
||||
if ! ci_required_for_pr "$_GARDENER_PR" 2>/dev/null; then
|
||||
log "CI not required (doc-only) — treating as passed"
|
||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
|
||||
"CI passed on PR #${_GARDENER_PR} (doc-only changes, CI not required).
|
||||
Write PHASE:awaiting_review to the phase file, then stop and wait:
|
||||
echo \"PHASE:awaiting_review\" > \"${PHASE_FILE}\""
|
||||
return 0
|
||||
fi
|
||||
|
||||
# No CI configured?
|
||||
if [ "${WOODPECKER_REPO_ID:-2}" = "0" ]; then
|
||||
log "no CI configured — treating as passed"
|
||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
|
||||
"CI passed on PR #${_GARDENER_PR} (no CI configured).
|
||||
Write PHASE:awaiting_review to the phase file, then stop and wait:
|
||||
echo \"PHASE:awaiting_review\" > \"${PHASE_FILE}\""
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Get HEAD SHA from PR
|
||||
local head_sha
|
||||
head_sha=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/pulls/${_GARDENER_PR}" | jq -r '.head.sha // empty') || true
|
||||
|
||||
if [ -z "$head_sha" ]; then
|
||||
log "WARNING: could not get HEAD SHA for PR #${_GARDENER_PR}"
|
||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
|
||||
"WARNING: Could not read HEAD SHA for PR #${_GARDENER_PR}. Verify push succeeded. Then write PHASE:awaiting_ci again."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Poll CI (15 min max within this phase)
|
||||
local ci_done=false ci_state="unknown" ci_elapsed=0 ci_timeout=900
|
||||
while [ "$ci_elapsed" -lt "$ci_timeout" ]; do
|
||||
sleep 30
|
||||
ci_elapsed=$((ci_elapsed + 30))
|
||||
|
||||
# Session health check
|
||||
if [ -f "/tmp/claude-exited-${_MONITOR_SESSION:-$SESSION_NAME}.ts" ] || \
|
||||
! tmux has-session -t "${_MONITOR_SESSION:-$SESSION_NAME}" 2>/dev/null; then
|
||||
log "session died during CI wait"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Merge-through timeout check
|
||||
elapsed=$(( $(date +%s) - _GARDENER_MERGE_START ))
|
||||
if [ "$elapsed" -ge "$_GARDENER_MERGE_TIMEOUT" ]; then
|
||||
_gardener_timeout_cleanup
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Re-fetch HEAD in case Claude pushed new commits
|
||||
head_sha=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/pulls/${_GARDENER_PR}" | jq -r '.head.sha // empty') || true
|
||||
|
||||
ci_state=$(ci_commit_status "$head_sha") || ci_state="unknown"
|
||||
|
||||
case "$ci_state" in
|
||||
success|failure|error) ci_done=true; break ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! $ci_done; then
|
||||
log "CI timeout for PR #${_GARDENER_PR}"
|
||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
|
||||
"CI TIMEOUT: CI did not complete within 15 minutes for PR #${_GARDENER_PR}. Write PHASE:failed with a reason if you cannot proceed."
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "CI: ${ci_state} for PR #${_GARDENER_PR}"
|
||||
|
||||
if [ "$ci_state" = "success" ]; then
|
||||
_GARDENER_CI_FIX_COUNT=0
|
||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
|
||||
"CI passed on PR #${_GARDENER_PR}.
|
||||
Write PHASE:awaiting_review to the phase file, then stop and wait:
|
||||
echo \"PHASE:awaiting_review\" > \"${PHASE_FILE}\""
|
||||
else
|
||||
_GARDENER_CI_FIX_COUNT=$(( _GARDENER_CI_FIX_COUNT + 1 ))
|
||||
if [ "$_GARDENER_CI_FIX_COUNT" -gt 3 ]; then
|
||||
log "CI exhausted after ${_GARDENER_CI_FIX_COUNT} attempts"
|
||||
printf 'PHASE:failed\nReason: gardener CI exhausted after %d attempts\n' \
|
||||
"$_GARDENER_CI_FIX_COUNT" > "$PHASE_FILE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Get error details
|
||||
local pipeline_num ci_error_log
|
||||
pipeline_num=$(ci_pipeline_number "$head_sha")
|
||||
|
||||
ci_error_log=""
|
||||
if [ -n "$pipeline_num" ]; then
|
||||
ci_error_log=$(bash "${FACTORY_ROOT}/lib/ci-debug.sh" failures "$pipeline_num" 2>/dev/null \
|
||||
| tail -80 | head -c 8000 || true)
|
||||
fi
|
||||
|
||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
|
||||
"CI failed on PR #${_GARDENER_PR} (attempt ${_GARDENER_CI_FIX_COUNT}/3).
|
||||
${ci_error_log:+Error output:
|
||||
${ci_error_log}
|
||||
}Fix the issue, commit, push, then write:
|
||||
echo \"PHASE:awaiting_ci\" > \"${PHASE_FILE}\"
|
||||
Then stop and wait."
|
||||
fi
|
||||
}
|
||||
|
||||
# shellcheck disable=SC2317 # called indirectly by monitor_phase_loop
|
||||
_gardener_handle_review() {
|
||||
log "waiting for review on PR #${_GARDENER_PR:-?}"
|
||||
_GARDENER_CI_FIX_COUNT=0 # Reset CI fix budget for next review cycle
|
||||
|
||||
local review_elapsed=0 review_timeout=1800
|
||||
while [ "$review_elapsed" -lt "$review_timeout" ]; do
|
||||
sleep 60 # 1 min between review checks (gardener PRs are fast-tracked)
|
||||
review_elapsed=$((review_elapsed + 60))
|
||||
|
||||
# Session health check
|
||||
if [ -f "/tmp/claude-exited-${_MONITOR_SESSION:-$SESSION_NAME}.ts" ] || \
|
||||
! tmux has-session -t "${_MONITOR_SESSION:-$SESSION_NAME}" 2>/dev/null; then
|
||||
log "session died during review wait"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Merge-through timeout check
|
||||
local elapsed
|
||||
elapsed=$(( $(date +%s) - _GARDENER_MERGE_START ))
|
||||
if [ "$elapsed" -ge "$_GARDENER_MERGE_TIMEOUT" ]; then
|
||||
_gardener_timeout_cleanup
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check if phase changed while we wait (e.g. review-poll injected feedback)
|
||||
local new_mtime
|
||||
new_mtime=$(stat -c %Y "$PHASE_FILE" 2>/dev/null || echo 0)
|
||||
if [ "$new_mtime" -gt "${LAST_PHASE_MTIME:-0}" ]; then
|
||||
log "phase changed during review wait — returning to monitor loop"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check for review on current HEAD
|
||||
local review_sha review_comment
|
||||
review_sha=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/pulls/${_GARDENER_PR}" | jq -r '.head.sha // empty') || true
|
||||
|
||||
review_comment=$(forge_api_all "/issues/${_GARDENER_PR}/comments" 2>/dev/null | \
|
||||
jq -r --arg sha "${review_sha:-none}" \
|
||||
'[.[] | select(.body | contains("<!-- reviewed: " + $sha))] | last // empty') || true
|
||||
|
||||
if [ -n "$review_comment" ] && [ "$review_comment" != "null" ]; then
|
||||
local review_text verdict
|
||||
review_text=$(echo "$review_comment" | jq -r '.body')
|
||||
|
||||
# Skip error reviews
|
||||
if echo "$review_text" | grep -q "review-error\|Review — Error"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
verdict=$(echo "$review_text" | grep -oP '\*\*(APPROVE|REQUEST_CHANGES|DISCUSS)\*\*' | head -1 | tr -d '*' || true)
|
||||
|
||||
# Check formal forge reviews as fallback
|
||||
if [ -z "$verdict" ]; then
|
||||
verdict=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/pulls/${_GARDENER_PR}/reviews" | \
|
||||
jq -r '[.[] | select(.stale == false)] | last | .state // empty' || true)
|
||||
[ "$verdict" = "APPROVED" ] && verdict="APPROVE"
|
||||
[[ "$verdict" != "REQUEST_CHANGES" && "$verdict" != "APPROVE" ]] && verdict=""
|
||||
fi
|
||||
|
||||
# Check review-poll sentinel to avoid double injection
|
||||
local review_sentinel="/tmp/review-injected-${PROJECT_NAME}-${_GARDENER_PR}"
|
||||
if [ -n "$verdict" ] && [ -f "$review_sentinel" ] && [ "$verdict" != "APPROVE" ]; then
|
||||
log "review already injected by review-poll — skipping"
|
||||
rm -f "$review_sentinel"
|
||||
break
|
||||
fi
|
||||
rm -f "$review_sentinel"
|
||||
|
||||
if [ "$verdict" = "APPROVE" ]; then
|
||||
log "gardener PR #${_GARDENER_PR} approved — merging"
|
||||
_gardener_merge
|
||||
return 0
|
||||
|
||||
elif [ "$verdict" = "REQUEST_CHANGES" ] || [ "$verdict" = "DISCUSS" ]; then
|
||||
_GARDENER_REVIEW_ROUND=$(( _GARDENER_REVIEW_ROUND + 1 ))
|
||||
log "review REQUEST_CHANGES on PR #${_GARDENER_PR} (round ${_GARDENER_REVIEW_ROUND})"
|
||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
|
||||
"Review feedback on PR #${_GARDENER_PR} (round ${_GARDENER_REVIEW_ROUND}):
|
||||
|
||||
${review_text}
|
||||
|
||||
Address all feedback, commit, push, then write:
|
||||
echo \"PHASE:awaiting_ci\" > \"${PHASE_FILE}\"
|
||||
Then stop and wait."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if PR was merged or closed externally
|
||||
local pr_json pr_state pr_merged
|
||||
pr_json=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/pulls/${_GARDENER_PR}") || true
|
||||
pr_state=$(echo "$pr_json" | jq -r '.state // "unknown"')
|
||||
pr_merged=$(echo "$pr_json" | jq -r '.merged // false')
|
||||
|
||||
if [ "$pr_merged" = "true" ]; then
|
||||
log "gardener PR #${_GARDENER_PR} merged externally"
|
||||
_gardener_execute_manifest
|
||||
printf 'PHASE:done\n' > "$PHASE_FILE"
|
||||
return 0
|
||||
fi
|
||||
if [ "$pr_state" != "open" ]; then
|
||||
log "gardener PR #${_GARDENER_PR} closed without merge"
|
||||
printf 'PHASE:failed\nReason: PR closed without merge\n' > "$PHASE_FILE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "waiting for review on PR #${_GARDENER_PR} (${review_elapsed}s)"
|
||||
done
|
||||
|
||||
if [ "$review_elapsed" -ge "$review_timeout" ]; then
|
||||
log "review wait timed out for PR #${_GARDENER_PR}"
|
||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
|
||||
"No review received after ${review_timeout}s for PR #${_GARDENER_PR}. Write PHASE:failed with a reason if you cannot proceed."
|
||||
fi
|
||||
}
|
||||
|
||||
# shellcheck disable=SC2317 # called indirectly by monitor_phase_loop
|
||||
_gardener_on_phase_change() {
|
||||
local phase="$1"
|
||||
log "phase: ${phase}"
|
||||
|
||||
case "$phase" in
|
||||
PHASE:awaiting_ci)
|
||||
_gardener_handle_ci
|
||||
;;
|
||||
PHASE:awaiting_review)
|
||||
_gardener_handle_review
|
||||
;;
|
||||
PHASE:done|PHASE:merged)
|
||||
agent_kill_session "${_MONITOR_SESSION:-$SESSION_NAME}"
|
||||
;;
|
||||
PHASE:failed|PHASE:escalate)
|
||||
agent_kill_session "${_MONITOR_SESSION:-$SESSION_NAME}"
|
||||
;;
|
||||
PHASE:crashed)
|
||||
if [ "${_GARDENER_CRASH_COUNT:-0}" -gt 0 ]; then
|
||||
log "ERROR: session crashed again — giving up"
|
||||
return 0
|
||||
fi
|
||||
_GARDENER_CRASH_COUNT=$(( _GARDENER_CRASH_COUNT + 1 ))
|
||||
log "WARNING: session crashed — attempting recovery"
|
||||
if create_agent_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
|
||||
"${_FORMULA_SESSION_WORKDIR:-$PROJECT_REPO_ROOT}" "$PHASE_FILE" 2>/dev/null; then
|
||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" "$PROMPT"
|
||||
log "recovery session started"
|
||||
else
|
||||
log "ERROR: could not restart session after crash"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
log "WARNING: unknown phase: ${phase}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── Reset result file ────────────────────────────────────────────────────
|
||||
rm -f "$RESULT_FILE"
|
||||
touch "$RESULT_FILE"
|
||||
|
||||
# ── Run session ──────────────────────────────────────────────────────────
|
||||
export CLAUDE_MODEL="sonnet"
|
||||
run_formula_and_monitor "gardener" 7200 "_gardener_on_phase_change"
|
||||
|
||||
# ── Cleanup on exit ──────────────────────────────────────────────────────
|
||||
# FINAL_PHASE already set by run_formula_and_monitor
|
||||
if [ "${FINAL_PHASE:-}" = "PHASE:done" ]; then
|
||||
rm -f "$SCRATCH_FILE"
|
||||
fi
|
||||
|
||||
# Write journal entry post-session
|
||||
profile_write_journal "gardener-run" "Gardener run $(date -u +%Y-%m-%d)" "complete" "" || true
|
||||
|
||||
rm -f "$GARDENER_PR_FILE"
|
||||
log "--- Gardener run done ---"
|
||||
[ -n "$_GARDENER_PR" ] && rm -f "/tmp/review-injected-${PROJECT_NAME}-${_GARDENER_PR}"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
[
|
||||
{
|
||||
"action": "edit_body",
|
||||
"issue": 356,
|
||||
"body": "## Problem\n\nThe entrypoint hardcodes `REPRODUCE_FORMULA` to `formulas/reproduce.toml` (line 26) and never checks the `DISINTO_FORMULA` environment variable passed by the dispatcher for triage runs.\n\nThe dispatcher sets `-e DISINTO_FORMULA=triage` for triage dispatch, but the entrypoint ignores it — always running the reproduce formula.\n\n## Fix\n\nAt line 26, select the formula based on `DISINTO_FORMULA`:\n\n```bash\ncase \"${DISINTO_FORMULA:-reproduce}\" in\n triage)\n ACTIVE_FORMULA=\"${DISINTO_DIR}/formulas/triage.toml\"\n ;;\n *)\n ACTIVE_FORMULA=\"${DISINTO_DIR}/formulas/reproduce.toml\"\n ;;\nesac\n```\n\nThen use `ACTIVE_FORMULA` everywhere `REPRODUCE_FORMULA` is currently used.\n\nAlso update log messages to reflect which formula is running (\"Starting triage-agent\" vs \"Starting reproduce-agent\").\n\n## Affected files\n\n- `docker/reproduce/entrypoint-reproduce.sh` — line 26 and all references to REPRODUCE_FORMULA\n\n## Acceptance criteria\n\n- [ ] `DISINTO_FORMULA=triage` selects `formulas/triage.toml` in the entrypoint\n- [ ] `DISINTO_FORMULA=reproduce` (or unset) still runs `formulas/reproduce.toml`\n- [ ] Log messages reflect which formula is active (\"Starting triage-agent\" / \"Starting reproduce-agent\")\n- [ ] All `REPRODUCE_FORMULA` references replaced with `ACTIVE_FORMULA`\n"
|
||||
"action": "comment",
|
||||
"issue": 3,
|
||||
"body": "Closing: this issue was fully implemented by PR #2 (commit `723167d`). All acceptance criteria are met:\n- `edge` (Caddy) service added to docker-compose\n- `staging` Caddy static file server configured\n- `docker/Caddyfile` template generated by `disinto init`\n- Default \"Nothing shipped yet\" staging page in `docker/staging-seed/`\n\nThe dev-agent sessions recorded here crashed after the implementation was already merged into `main`. Closing as implemented."
|
||||
},
|
||||
{
|
||||
"action": "close",
|
||||
"issue": 3,
|
||||
"reason": "already implemented in PR #2 (commit 723167d)"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
16
gardener/recipes/cascade-rebase.toml
Normal file
16
gardener/recipes/cascade-rebase.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# gardener/recipes/cascade-rebase.toml — PR outdated after main moved
|
||||
#
|
||||
# Trigger: PR mergeable=false (stale branch or dismissed approval)
|
||||
# Playbook: rebase only — merge and re-approval happen on subsequent cycles
|
||||
# after CI reruns on the rebased branch (rebase is async via Gitea API)
|
||||
|
||||
name = "cascade-rebase"
|
||||
description = "PR outdated after main moved — mergeable=false or stale approval"
|
||||
priority = 20
|
||||
|
||||
[trigger]
|
||||
pr_mergeable = false
|
||||
|
||||
[[playbook]]
|
||||
action = "rebase-pr"
|
||||
description = "Rebase PR onto main (async — CI reruns, merge on next cycle)"
|
||||
25
gardener/recipes/chicken-egg-ci.toml
Normal file
25
gardener/recipes/chicken-egg-ci.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# gardener/recipes/chicken-egg-ci.toml — PR introduces CI step that fails on pre-existing code
|
||||
#
|
||||
# Trigger: New .woodpecker/*.yml in PR + lint/check step + failures on unchanged files
|
||||
# Playbook: make step non-blocking, create per-file issues, create follow-up to remove bypass
|
||||
|
||||
name = "chicken-egg-ci"
|
||||
description = "PR introduces a CI pipeline/linting step that fails on pre-existing code"
|
||||
priority = 10
|
||||
|
||||
[trigger]
|
||||
pr_files = '\.woodpecker/.*\.yml$'
|
||||
step_name = '(?i)(lint|shellcheck|check)'
|
||||
failures_on_unchanged = true
|
||||
|
||||
[[playbook]]
|
||||
action = "make-step-non-blocking"
|
||||
description = "Make failing step non-blocking (|| true) in the PR"
|
||||
|
||||
[[playbook]]
|
||||
action = "lint-per-file"
|
||||
description = "Create per-file fix issues for pre-existing violations (generic linter support)"
|
||||
|
||||
[[playbook]]
|
||||
action = "create-followup-remove-bypass"
|
||||
description = "Create follow-up issue to remove || true once fixes land"
|
||||
20
gardener/recipes/flaky-test.toml
Normal file
20
gardener/recipes/flaky-test.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# gardener/recipes/flaky-test.toml — CI fails intermittently
|
||||
#
|
||||
# Trigger: Test step fails + multiple CI attempts (same step, different output)
|
||||
# Playbook: retrigger CI (max 2x), quarantine test if still failing
|
||||
|
||||
name = "flaky-test"
|
||||
description = "CI fails intermittently — same step fails across multiple attempts"
|
||||
priority = 30
|
||||
|
||||
[trigger]
|
||||
step_name = '(?i)test'
|
||||
min_attempts = 2
|
||||
|
||||
[[playbook]]
|
||||
action = "retrigger-ci"
|
||||
description = "Retrigger CI (max 2 retries)"
|
||||
|
||||
[[playbook]]
|
||||
action = "quarantine-test"
|
||||
description = "If still failing, quarantine test and create fix issue"
|
||||
20
gardener/recipes/shellcheck-violations.toml
Normal file
20
gardener/recipes/shellcheck-violations.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# gardener/recipes/shellcheck-violations.toml — ShellCheck step fails
|
||||
#
|
||||
# Trigger: Step named *shellcheck* fails with SC#### codes in output
|
||||
# Playbook: parse per-file, create one issue per file, label backlog
|
||||
|
||||
name = "shellcheck-violations"
|
||||
description = "ShellCheck step fails with SC#### codes in output"
|
||||
priority = 40
|
||||
|
||||
[trigger]
|
||||
step_name = '(?i)shellcheck'
|
||||
output = 'SC\d{4}'
|
||||
|
||||
[[playbook]]
|
||||
action = "shellcheck-per-file"
|
||||
description = "Parse output by file, create one fix issue per file with specific SC codes"
|
||||
|
||||
[[playbook]]
|
||||
action = "label-backlog"
|
||||
description = "Label created issues as backlog"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
<!-- last-reviewed: ac2beac361503c8712ecfc72be0401b5968cce4e -->
|
||||
<!-- last-reviewed: 9b91c6a5bdd83513ca262dd468ea14db675971c1 -->
|
||||
# Shared Helpers (`lib/`)
|
||||
|
||||
All agents source `lib/env.sh` as their first action. Additional helpers are
|
||||
|
|
@ -6,29 +6,16 @@ sourced as needed.
|
|||
|
||||
| File | What it provides | Sourced by |
|
||||
|---|---|---|
|
||||
| `lib/env.sh` | Loads `.env`, sets `FACTORY_ROOT`, exports project config (`FORGE_REPO`, `PROJECT_NAME`, etc.), defines `log()`, `forge_api()`, `forge_api_all()` (paginates all pages; accepts optional second TOKEN parameter, defaults to `$FORGE_TOKEN`; handles invalid/empty JSON responses gracefully — returns empty on parse error instead of crashing), `woodpecker_api()`, `wpdb()`, `memory_guard()` (skips agent if RAM < threshold). Auto-loads project TOML if `PROJECT_TOML` is set. Exports per-agent tokens (`FORGE_PLANNER_TOKEN`, `FORGE_GARDENER_TOKEN`, `FORGE_VAULT_TOKEN`, `FORGE_SUPERVISOR_TOKEN`, `FORGE_PREDICTOR_TOKEN`) — each falls back to `$FORGE_TOKEN` if not set. **Vault-only token guard (AD-006)**: `unset GITHUB_TOKEN CLAWHUB_TOKEN` so agents never hold external-action tokens — only the runner container receives them. **Container note**: when `DISINTO_CONTAINER=1`, `.env` is NOT re-sourced — compose already injects env vars (including `FORGE_URL=http://forgejo:3000`) and re-sourcing would clobber them. **Save/restore scope (#364)**: only `FORGE_URL` is preserved across `.env` re-sourcing (compose injects `http://forgejo:3000`, `.env` has `http://localhost:3000`). `FORGE_TOKEN` is NOT preserved so refreshed tokens in `.env` take effect immediately. **Required env var**: `FORGE_PASS` — bot password for git HTTP push (Forgejo 11.x rejects API tokens for `git push`, #361). | Every agent |
|
||||
| `lib/ci-helpers.sh` | `ci_passed()` — returns 0 if CI state is "success" (or no CI configured). `ci_required_for_pr()` — returns 0 if PR has code files (CI required), 1 if non-code only (CI not required). `is_infra_step()` — returns 0 if a single CI step failure matches infra heuristics (clone/git exit 128, any exit 137, log timeout patterns). `classify_pipeline_failure()` — returns "infra \<reason>" if any failed Woodpecker step matches infra heuristics via `is_infra_step()`, else "code". `ensure_priority_label()` — looks up (or creates) the `priority` label and returns its ID; caches in `_PRIORITY_LABEL_ID`. `ci_commit_status <sha>` — queries Woodpecker directly for CI state, falls back to forge commit status API. `ci_pipeline_number <sha>` — returns the Woodpecker pipeline number for a commit, falls back to parsing forge status `target_url`. `ci_promote <repo_id> <pipeline_num> <environment>` — promotes a pipeline to a named Woodpecker environment (vault-gated deployment: vault approves, vault-fire calls this — vault redesign in progress, see #73-#77). `ci_get_logs <pipeline_number> [--step <name>]` — reads CI logs from Woodpecker SQLite database via `lib/ci-log-reader.py`; outputs last 200 lines to stdout. Requires mounted woodpecker-data volume at /woodpecker-data. | dev-poll, review-poll, review-pr |
|
||||
| `lib/env.sh` | Loads `.env`, sets `FACTORY_ROOT`, exports project config (`FORGE_REPO`, `PROJECT_NAME`, etc.), defines `log()`, `forge_api()`, `forge_api_all()` (accepts optional second TOKEN parameter, defaults to `$FORGE_TOKEN`), `woodpecker_api()`, `wpdb()`, `memory_guard()` (skips agent if RAM < threshold). Auto-loads project TOML if `PROJECT_TOML` is set. Exports per-agent tokens (`FORGE_PLANNER_TOKEN`, `FORGE_GARDENER_TOKEN`, `FORGE_VAULT_TOKEN`, `FORGE_SUPERVISOR_TOKEN`, `FORGE_PREDICTOR_TOKEN`, `FORGE_ACTION_TOKEN`) — each falls back to `$FORGE_TOKEN` if not set. **Vault-only token guard (AD-006)**: `unset GITHUB_TOKEN CLAWHUB_TOKEN` so agents never hold external-action tokens — only the vault-runner container receives them. **Container note**: when `DISINTO_CONTAINER=1`, `.env` is NOT re-sourced — compose already injects env vars (including `FORGE_URL=http://forgejo:3000`) and re-sourcing would clobber them. | Every agent |
|
||||
| `lib/ci-helpers.sh` | `ci_passed()` — returns 0 if CI state is "success" (or no CI configured). `ci_required_for_pr()` — returns 0 if PR has code files (CI required), 1 if non-code only (CI not required). `is_infra_step()` — returns 0 if a single CI step failure matches infra heuristics (clone/git exit 128, any exit 137, log timeout patterns). `classify_pipeline_failure()` — returns "infra \<reason>" if any failed Woodpecker step matches infra heuristics via `is_infra_step()`, else "code". `ensure_priority_label()` — looks up (or creates) the `priority` label and returns its ID; caches in `_PRIORITY_LABEL_ID`. `ci_commit_status <sha>` — queries Woodpecker directly for CI state, falls back to forge commit status API. `ci_pipeline_number <sha>` — returns the Woodpecker pipeline number for a commit, falls back to parsing forge status `target_url`. `ci_promote <repo_id> <pipeline_num> <environment>` — promotes a pipeline to a named Woodpecker environment (vault-gated deployment: vault approves, vault-fire calls this). | dev-poll, review-poll, review-pr, supervisor-poll |
|
||||
| `lib/ci-debug.sh` | CLI tool for Woodpecker CI: `list`, `status`, `logs`, `failures` subcommands. Not sourced — run directly. | Humans / dev-agent (tool access) |
|
||||
| `lib/ci-log-reader.py` | Python tool: reads CI logs from Woodpecker SQLite database. `<pipeline_number> [--step <name>]` — returns last 200 lines from failed steps (or specified step). Used by `ci_get_logs()` in ci-helpers.sh. Requires `WOODPECKER_DATA_DIR` (default: /woodpecker-data). | ci-helpers.sh |
|
||||
| `lib/load-project.sh` | Parses a `projects/*.toml` file into env vars (`PROJECT_NAME`, `FORGE_REPO`, `WOODPECKER_REPO_ID`, monitoring toggles, mirror config, etc.). Also exports `FORGE_REPO_OWNER` (the owner component of `FORGE_REPO`, e.g. `disinto-admin` from `disinto-admin/disinto`). | env.sh (when `PROJECT_TOML` is set) |
|
||||
| `lib/parse-deps.sh` | Extracts dependency issue numbers from an issue body (stdin → stdout, one number per line). Matches `## Dependencies` / `## Depends on` / `## Blocked by` sections and inline `depends on #N` / `blocked by #N` patterns. Inline scan skips fenced code blocks to prevent false positives from code examples in issue bodies. Not sourced — executed via `bash lib/parse-deps.sh`. | dev-poll |
|
||||
| `lib/formula-session.sh` | `acquire_cron_lock()`, `load_formula()`, `load_formula_or_profile()`, `build_context_block()`, `ensure_ops_repo()`, `ops_commit_and_push()`, `build_prompt_footer()`, `build_sdk_prompt_footer()`, `formula_worktree_setup()`, `formula_prepare_profile_context()`, `formula_lessons_block()`, `profile_write_journal()`, `profile_load_lessons()`, `ensure_profile_repo()`, `_profile_has_repo()`, `_count_undigested_journals()`, `_profile_digest_journals()`, `_profile_commit_and_push()`, `resolve_agent_identity()`, `build_graph_section()`, `build_scratch_instruction()`, `read_scratch_context()`, `cleanup_stale_crashed_worktrees()` — shared helpers for formula-driven cron agents (lock, .profile repo management, prompt assembly, worktree setup). Memory guard is provided by `memory_guard()` in `lib/env.sh` (not duplicated here). `resolve_agent_identity()` — sets `FORGE_TOKEN`, `AGENT_IDENTITY`, `FORGE_REMOTE` from per-agent token env vars and FORGE_URL remote detection. `build_graph_section()` generates the structural-analysis section (runs `lib/build-graph.py`, formats JSON output) — previously duplicated in planner-run.sh and predictor-run.sh, now shared here. `cleanup_stale_crashed_worktrees()` — thin wrapper around `worktree_cleanup_stale()` from `lib/worktree.sh` (kept for backwards compatibility). | planner-run.sh, predictor-run.sh, gardener-run.sh, supervisor-run.sh, dev-agent.sh |
|
||||
| `lib/guard.sh` | `check_active(agent_name)` — reads `$FACTORY_ROOT/state/.{agent_name}-active`; exits 0 (skip) if the file is absent. Factory is off by default — state files must be created to enable each agent. **Logs a message to stderr** when skipping (`[check_active] SKIP: state file not found`), so agent dropout is visible in cron logs. Sourced by dev-poll.sh, review-poll.sh, predictor-run.sh, supervisor-run.sh. | cron entry points |
|
||||
| `lib/mirrors.sh` | `mirror_push()` — pushes `$PRIMARY_BRANCH` + tags to all configured mirror remotes (fire-and-forget background pushes). Reads `MIRROR_NAMES` and `MIRROR_*` vars exported by `load-project.sh` from the `[mirrors]` TOML section. Failures are logged but never block the pipeline. Sourced by dev-poll.sh — called after every successful merge. | dev-poll.sh |
|
||||
| `lib/load-project.sh` | Parses a `projects/*.toml` file into env vars (`PROJECT_NAME`, `FORGE_REPO`, `WOODPECKER_REPO_ID`, monitoring toggles, mirror config, etc.). | env.sh (when `PROJECT_TOML` is set), supervisor-poll (per-project iteration) |
|
||||
| `lib/parse-deps.sh` | Extracts dependency issue numbers from an issue body (stdin → stdout, one number per line). Matches `## Dependencies` / `## Depends on` / `## Blocked by` sections and inline `depends on #N` / `blocked by #N` patterns. Inline scan skips fenced code blocks to prevent false positives from code examples in issue bodies. Not sourced — executed via `bash lib/parse-deps.sh`. | dev-poll, supervisor-poll |
|
||||
| `lib/formula-session.sh` | `acquire_cron_lock()`, `check_memory()`, `load_formula()`, `build_context_block()`, `consume_escalation_reply()`, `start_formula_session()`, `formula_phase_callback()`, `build_prompt_footer()`, `build_graph_section()`, `run_formula_and_monitor(AGENT [TIMEOUT] [CALLBACK])` — shared helpers for formula-driven cron agents (lock, memory guard, formula loading, prompt assembly, tmux session, monitor loop, crash recovery). `build_graph_section()` generates the structural-analysis section (runs `lib/build-graph.py`, formats JSON output) — previously duplicated in planner-run.sh and predictor-run.sh, now shared here. `formula_phase_callback()` handles `PHASE:escalate` (unified escalation path — kills the session). `run_formula_and_monitor` accepts an optional CALLBACK (default: `formula_phase_callback`) so callers can install custom merge-through or escalation handlers. | planner-run.sh, predictor-run.sh, gardener-run.sh, supervisor-run.sh, dev-agent.sh, action-agent.sh |
|
||||
| `lib/guard.sh` | `check_active(agent_name)` — reads `$FACTORY_ROOT/state/.{agent_name}-active`; exits 0 (skip) if the file is absent. Factory is off by default — state files must be created to enable each agent. **Logs a message to stderr** when skipping (`[check_active] SKIP: state file not found`), so agent dropout is visible in cron logs. Sourced by dev-poll.sh, review-poll.sh, action-poll.sh, predictor-run.sh, supervisor-run.sh. | cron entry points |
|
||||
| `lib/mirrors.sh` | `mirror_push()` — pushes `$PRIMARY_BRANCH` + tags to all configured mirror remotes (fire-and-forget background pushes). Reads `MIRROR_NAMES` and `MIRROR_*` vars exported by `load-project.sh` from the `[mirrors]` TOML section. Failures are logged but never block the pipeline. Sourced by dev-poll.sh and dev/phase-handler.sh — called after every successful merge. | dev-poll.sh, phase-handler.sh |
|
||||
| `lib/build-graph.py` | Python tool: parses VISION.md, prerequisites.md (from ops repo), AGENTS.md, formulas/*.toml, evidence/ (from ops repo), and forge issues/labels into a NetworkX DiGraph. Runs structural analyses (orphaned objectives, stale prerequisites, thin evidence, circular deps) and outputs a JSON report. Used by `review-pr.sh` (per-PR changed-file analysis) and `predictor-run.sh` (full-project analysis) to provide structural context to Claude. | review-pr.sh, predictor-run.sh |
|
||||
| `lib/secret-scan.sh` | `scan_for_secrets()` — detects potential secrets (API keys, bearer tokens, private keys, URLs with embedded credentials) in text; returns 1 if secrets found. `redact_secrets()` — replaces detected secret patterns with `[REDACTED]`. | issue-lifecycle.sh |
|
||||
| `lib/stack-lock.sh` | File-based lock protocol for singleton project stack access. `stack_lock_acquire(holder, project)` — polls until free, breaks stale heartbeats (>10 min old), claims lock. `stack_lock_release(project)` — deletes lock file. `stack_lock_check(project)` — inspect current lock state. `stack_lock_heartbeat(project)` — update heartbeat timestamp (callers must call every 2 min while holding). Lock files at `~/data/locks/<project>-stack.lock`. | docker/edge/dispatcher.sh, reproduce formula |
|
||||
| `lib/secret-scan.sh` | `scan_for_secrets()` — detects potential secrets (API keys, bearer tokens, private keys, URLs with embedded credentials) in text; returns 1 if secrets found. `redact_secrets()` — replaces detected secret patterns with `[REDACTED]`. | file-action-issue.sh, phase-handler.sh |
|
||||
| `lib/file-action-issue.sh` | `file_action_issue()` — dedup check, secret scan, label lookup, and issue creation for formula-driven cron wrappers. Sets `FILED_ISSUE_NUM` on success. Returns 4 if secrets detected in body. | (available for future use) |
|
||||
| `lib/tea-helpers.sh` | `tea_file_issue(title, body, labels...)` — create issue via tea CLI with secret scanning; sets `FILED_ISSUE_NUM`. `tea_relabel(issue_num, labels...)` — replace labels using tea's `edit` subcommand (not `label`). `tea_comment(issue_num, body)` — add comment with secret scanning. `tea_close(issue_num)` — close issue. All use `TEA_LOGIN` and `FORGE_REPO` from env.sh. Labels by name (no ID lookup). Tea binary download verified via sha256 checksum. Sourced by env.sh when `tea` binary is available. | env.sh (conditional) |
|
||||
| `lib/worktree.sh` | Reusable git worktree management: `worktree_create(path, branch, [base_ref])` — create worktree, checkout base, fetch submodules. `worktree_recover(path, branch, [remote])` — detect existing worktree, reuse if on correct branch (sets `_WORKTREE_REUSED`), otherwise clean and recreate. `worktree_cleanup(path)` — `git worktree remove --force`, clear Claude Code project cache (`~/.claude/projects/` matching path). `worktree_cleanup_stale([max_age_hours])` — scan `/tmp` for orphaned worktrees older than threshold, skip preserved and active tmux worktrees, prune. `worktree_preserve(path, reason)` — mark worktree as preserved for debugging (writes `.worktree-preserved` marker, skipped by stale cleanup). | dev-agent.sh, supervisor-run.sh, planner-run.sh, predictor-run.sh, gardener-run.sh |
|
||||
| `lib/pr-lifecycle.sh` | Reusable PR lifecycle library: `pr_create()`, `pr_find_by_branch()`, `pr_poll_ci()`, `pr_poll_review()`, `pr_merge()`, `pr_is_merged()`, `pr_walk_to_merge()`, `build_phase_protocol_prompt()`. Requires `lib/ci-helpers.sh`. | dev-agent.sh (future) |
|
||||
| `lib/issue-lifecycle.sh` | Reusable issue lifecycle library: `issue_claim()` (add in-progress, remove backlog), `issue_release()` (remove in-progress, add backlog), `issue_block()` (post diagnostic comment with secret redaction, add blocked label), `issue_close()`, `issue_check_deps()` (parse deps, check transitive closure; sets `_ISSUE_BLOCKED_BY`, `_ISSUE_SUGGESTION`), `issue_suggest_next()` (find next unblocked backlog issue; sets `_ISSUE_NEXT`), `issue_post_refusal()` (structured refusal comment with dedup). Label IDs cached in globals on first lookup. Sources `lib/secret-scan.sh`. | dev-agent.sh (future) |
|
||||
| `lib/vault.sh` | **Vault PR helper** — create vault action PRs on ops repo via Forgejo API (works from containers without SSH). `vault_request <action_id> <toml_content>` validates TOML (using `validate_vault_action` from `vault/vault-env.sh`), creates branch `vault/<action-id>`, writes `vault/actions/<action-id>.toml`, creates PR targeting `main` with title `vault: <action-id>` and body from context field, returns PR number. Idempotent: if PR exists, returns existing number. Requires `FORGE_TOKEN`, `FORGE_URL`, `FORGE_REPO`, `FORGE_OPS_REPO`. Uses the calling agent's own token (saves/restores `FORGE_TOKEN` around sourcing `vault-env.sh`), so approval workflow respects individual agent identities. | dev-agent (vault actions), future vault dispatcher |
|
||||
| `lib/branch-protection.sh` | Branch protection helpers for Forgejo repos. `setup_vault_branch_protection()` — configures admin-only merge protection on main (require 1 approval, restrict merge to admin role, block direct pushes). `setup_profile_branch_protection()` — same protection for `.profile` repos. `verify_branch_protection()` — checks protection is correctly configured. `remove_branch_protection()` — removes protection (cleanup/testing). Handles race condition after initial push: retries with backoff if Forgejo hasn't processed the branch yet. Requires `FORGE_TOKEN`, `FORGE_URL`, `FORGE_OPS_REPO`. | bin/disinto (hire-an-agent) |
|
||||
| `lib/agent-sdk.sh` | `agent_run([--resume SESSION_ID] [--worktree DIR] PROMPT)` — one-shot `claude -p` invocation with session persistence. Saves session ID to `SID_FILE`, reads it back on resume. `agent_recover_session()` — restore previous session ID from `SID_FILE` on startup. **Nudge guard**: skips nudge injection if the worktree is clean and no push is expected, preventing spurious re-invocations. Callers must define `SID_FILE`, `LOGFILE`, and `log()` before sourcing. | formula-driven agents (dev-agent, planner-run, predictor-run, gardener-run) |
|
||||
| `lib/forge-setup.sh` | `setup_forge()` — Forgejo instance provisioning: creates admin user, bot accounts, org, repos (code + ops), configures webhooks, sets repo topics. Extracted from `bin/disinto`. Requires `FORGE_URL`, `FORGE_TOKEN`, `FACTORY_ROOT`. **Password storage (#361)**: after creating each bot account, stores its password in `.env` as `FORGE_<BOT>_PASS` (e.g. `FORGE_PASS`, `FORGE_REVIEW_PASS`, etc.) for use by `forge-push.sh`. | bin/disinto (init) |
|
||||
| `lib/forge-push.sh` | `push_to_forge()` — pushes a local clone to the Forgejo remote and verifies the push. `_assert_forge_push_globals()` validates required env vars before use. Requires `FORGE_URL`, `FORGE_PASS`, `FACTORY_ROOT`, `PRIMARY_BRANCH`. **Auth**: uses `FORGE_PASS` (bot password) for git HTTP push — Forgejo 11.x rejects API tokens for `git push` (#361). | bin/disinto (init) |
|
||||
| `lib/ops-setup.sh` | `setup_ops_repo()` — creates ops repo on Forgejo if it doesn't exist, configures bot collaborators, clones/initializes ops repo locally, seeds directory structure (vault, knowledge, evidence). Exports `_ACTUAL_OPS_SLUG`. | bin/disinto (init) |
|
||||
| `lib/ci-setup.sh` | `_install_cron_impl()` — installs crontab entries for project agents. `_create_woodpecker_oauth_impl()` — creates OAuth2 app on Forgejo for Woodpecker. `_generate_woodpecker_token_impl()` — auto-generates WOODPECKER_TOKEN via OAuth2 flow. `_activate_woodpecker_repo_impl()` — activates repo in Woodpecker. All gated by `_load_ci_context()` which validates required env vars. | bin/disinto (init) |
|
||||
| `lib/generators.sh` | Template generation for `disinto init`: `generate_compose()` — docker-compose.yml, `generate_caddyfile()` — Caddyfile, `generate_staging_index()` — staging index, `generate_deploy_pipelines()` — Woodpecker deployment pipeline configs. Requires `FACTORY_ROOT`, `PROJECT_NAME`, `PRIMARY_BRANCH`. | bin/disinto (init) |
|
||||
| `lib/hire-agent.sh` | `disinto_hire_an_agent()` — user creation, `.profile` repo setup, formula copying, branch protection, and state marker creation for hiring a new agent. Requires `FORGE_URL`, `FORGE_TOKEN`, `FACTORY_ROOT`, `PROJECT_NAME`. Extracted from `bin/disinto`. | bin/disinto (hire) |
|
||||
| `lib/release.sh` | `disinto_release()` — vault TOML creation, branch setup on ops repo, PR creation, and auto-merge request for a versioned release. `_assert_release_globals()` validates required env vars. Requires `FORGE_URL`, `FORGE_TOKEN`, `FORGE_OPS_REPO`, `FACTORY_ROOT`, `PRIMARY_BRANCH`. Extracted from `bin/disinto`. | bin/disinto (release) |
|
||||
| `lib/agent-session.sh` | Shared tmux + Claude session helpers: `create_agent_session()`, `inject_formula()`, `agent_wait_for_claude_ready()`, `agent_inject_into_session()`, `agent_kill_session()`, `monitor_phase_loop()`, `read_phase()`, `write_compact_context()`. `create_agent_session(session, workdir, [phase_file])` optionally installs a PostToolUse hook (matcher `Bash\|Write`) that detects phase file writes in real-time — when Claude writes to the phase file, the hook writes a marker so `monitor_phase_loop` reacts on the next poll instead of waiting for mtime changes. Also installs a StopFailure hook (matcher `rate_limit\|server_error\|authentication_failed\|billing_error`) that writes `PHASE:failed` with an `api_error` reason to the phase file and touches the phase-changed marker, so the orchestrator discovers API errors within one poll cycle instead of waiting for idle timeout. Also installs a SessionStart hook (matcher `compact`) that re-injects phase protocol instructions after context compaction — callers write the context file via `write_compact_context(phase_file, content)`, and the hook (`on-compact-reinject.sh`) outputs the file content to stdout so Claude retains critical instructions. When `phase_file` is set, passes it to the idle stop hook (`on-idle-stop.sh`) so the hook can **nudge Claude** (up to 2 times) if Claude returns to the prompt without writing to the phase file — the hook injects a tmux reminder asking Claude to signal PHASE:done or PHASE:awaiting_ci. The PreToolUse guard hook (`on-pretooluse-guard.sh`) receives the session name as a third argument — formula agents (`gardener-*`, `planner-*`, `predictor-*`, `supervisor-*`) are identified this way and allowed to access `FACTORY_ROOT` from worktrees (they need env.sh, AGENTS.md, formulas/, lib/). **OAuth flock**: when `DISINTO_CONTAINER=1`, Claude CLI is wrapped in `flock -w 300 ~/.claude/session.lock` to queue concurrent token refresh attempts and prevent rotation races across agents sharing the same credentials. `monitor_phase_loop` sets `_MONITOR_LOOP_EXIT` to one of: `done`, `idle_timeout`, `idle_prompt` (Claude returned to `>` for 3 consecutive polls without writing any phase — callback invoked with `PHASE:failed`, session already dead), `crashed`, or `PHASE:escalate` / other `PHASE:*` string. **Unified escalation**: `PHASE:escalate` is the signal that a session needs human input (renamed from `PHASE:needs_human`). **Callers must handle `idle_prompt`** in both their callback and their post-loop exit handler — see [`docs/PHASE-PROTOCOL.md` idle_prompt](docs/PHASE-PROTOCOL.md#idle_prompt-exit-reason) for the full contract. | dev-agent.sh, action-agent.sh |
|
||||
|
|
|
|||
116
lib/agent-sdk.sh
116
lib/agent-sdk.sh
|
|
@ -1,116 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# agent-sdk.sh — Shared SDK for synchronous Claude agent invocations
|
||||
#
|
||||
# Provides agent_run(): one-shot `claude -p` with session persistence.
|
||||
# Source this from any agent script after defining:
|
||||
# SID_FILE — path to persist session ID (e.g. /tmp/dev-session-proj-123.sid)
|
||||
# LOGFILE — path for log output
|
||||
# log() — logging function
|
||||
#
|
||||
# Usage:
|
||||
# source "$(dirname "$0")/../lib/agent-sdk.sh"
|
||||
# agent_run [--resume SESSION_ID] [--worktree DIR] PROMPT
|
||||
#
|
||||
# After each call, _AGENT_SESSION_ID holds the session ID (also saved to SID_FILE).
|
||||
# Call agent_recover_session() on startup to restore a previous session.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
_AGENT_SESSION_ID=""
|
||||
|
||||
# agent_recover_session — restore session_id from SID_FILE if it exists.
|
||||
# Call this before agent_run --resume to enable session continuity.
|
||||
agent_recover_session() {
|
||||
if [ -f "$SID_FILE" ]; then
|
||||
_AGENT_SESSION_ID=$(cat "$SID_FILE")
|
||||
log "agent_recover_session: ${_AGENT_SESSION_ID:0:12}..."
|
||||
fi
|
||||
}
|
||||
|
||||
# agent_run — synchronous Claude invocation (one-shot claude -p)
|
||||
# Usage: agent_run [--resume SESSION_ID] [--worktree DIR] PROMPT
|
||||
# Sets: _AGENT_SESSION_ID (updated each call, persisted to SID_FILE)
|
||||
agent_run() {
|
||||
local resume_id="" worktree_dir=""
|
||||
while [[ "${1:-}" == --* ]]; do
|
||||
case "$1" in
|
||||
--resume) shift; resume_id="${1:-}"; shift ;;
|
||||
--worktree) shift; worktree_dir="${1:-}"; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
local prompt="${1:-}"
|
||||
|
||||
local -a args=(-p "$prompt" --output-format json --dangerously-skip-permissions --max-turns 200)
|
||||
[ -n "$resume_id" ] && args+=(--resume "$resume_id")
|
||||
[ -n "${CLAUDE_MODEL:-}" ] && args+=(--model "$CLAUDE_MODEL")
|
||||
|
||||
local run_dir="${worktree_dir:-$(pwd)}"
|
||||
local lock_file="${HOME}/.claude/session.lock"
|
||||
mkdir -p "$(dirname "$lock_file")"
|
||||
local output rc
|
||||
log "agent_run: starting (resume=${resume_id:-(new)}, dir=${run_dir})"
|
||||
output=$(cd "$run_dir" && flock -w 600 "$lock_file" timeout "${CLAUDE_TIMEOUT:-7200}" claude "${args[@]}" 2>>"$LOGFILE") && rc=0 || rc=$?
|
||||
if [ "$rc" -eq 124 ]; then
|
||||
log "agent_run: timeout after ${CLAUDE_TIMEOUT:-7200}s (exit code $rc)"
|
||||
elif [ "$rc" -ne 0 ]; then
|
||||
log "agent_run: claude exited with code $rc"
|
||||
# Log last 3 lines of output for diagnostics
|
||||
if [ -n "$output" ]; then
|
||||
log "agent_run: last output lines: $(echo "$output" | tail -3)"
|
||||
fi
|
||||
fi
|
||||
if [ -z "$output" ]; then
|
||||
log "agent_run: empty output (claude may have crashed or failed, exit code: $rc)"
|
||||
fi
|
||||
|
||||
# Extract and persist session_id
|
||||
local new_sid
|
||||
new_sid=$(printf '%s' "$output" | jq -r '.session_id // empty' 2>/dev/null) || true
|
||||
if [ -n "$new_sid" ]; then
|
||||
_AGENT_SESSION_ID="$new_sid"
|
||||
printf '%s' "$new_sid" > "$SID_FILE"
|
||||
log "agent_run: session_id=${new_sid:0:12}..."
|
||||
fi
|
||||
|
||||
# Save output for diagnostics (no_push, crashes)
|
||||
_AGENT_LAST_OUTPUT="$output"
|
||||
local diag_file="${DISINTO_LOG_DIR:-/tmp}/dev/agent-run-last.json"
|
||||
printf '%s' "$output" > "$diag_file" 2>/dev/null || true
|
||||
|
||||
# Nudge: if the model stopped without pushing, resume with encouragement.
|
||||
# Some models emit end_turn prematurely when confused. A nudge often unsticks them.
|
||||
if [ -n "$_AGENT_SESSION_ID" ] && [ -n "$output" ]; then
|
||||
local has_changes
|
||||
has_changes=$(cd "$run_dir" && git status --porcelain 2>/dev/null | head -1) || true
|
||||
local has_pushed
|
||||
has_pushed=$(cd "$run_dir" && git log --oneline "${FORGE_REMOTE:-origin}/${PRIMARY_BRANCH:-main}..HEAD" 2>/dev/null | head -1) || true
|
||||
if [ -z "$has_pushed" ]; then
|
||||
if [ -n "$has_changes" ]; then
|
||||
# Nudge: there are uncommitted changes
|
||||
local nudge="You stopped but did not push any code. You have uncommitted changes. Commit them and push."
|
||||
log "agent_run: nudging (uncommitted changes)"
|
||||
local nudge_rc
|
||||
output=$(cd "$run_dir" && flock -w 600 "$lock_file" timeout "${CLAUDE_TIMEOUT:-7200}" claude -p "$nudge" --resume "$_AGENT_SESSION_ID" --output-format json --dangerously-skip-permissions --max-turns 50 ${CLAUDE_MODEL:+--model "$CLAUDE_MODEL"} 2>>"$LOGFILE") && nudge_rc=0 || nudge_rc=$?
|
||||
if [ "$nudge_rc" -eq 124 ]; then
|
||||
log "agent_run: nudge timeout after ${CLAUDE_TIMEOUT:-7200}s (exit code $nudge_rc)"
|
||||
elif [ "$nudge_rc" -ne 0 ]; then
|
||||
log "agent_run: nudge claude exited with code $nudge_rc"
|
||||
# Log last 3 lines of output for diagnostics
|
||||
if [ -n "$output" ]; then
|
||||
log "agent_run: nudge last output lines: $(echo "$output" | tail -3)"
|
||||
fi
|
||||
fi
|
||||
new_sid=$(printf '%s' "$output" | jq -r '.session_id // empty' 2>/dev/null) || true
|
||||
if [ -n "$new_sid" ]; then
|
||||
_AGENT_SESSION_ID="$new_sid"
|
||||
printf '%s' "$new_sid" > "$SID_FILE"
|
||||
fi
|
||||
printf '%s' "$output" > "$diag_file" 2>/dev/null || true
|
||||
_AGENT_LAST_OUTPUT="$output"
|
||||
else
|
||||
log "agent_run: no push and no changes — skipping nudge"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
486
lib/agent-session.sh
Normal file
486
lib/agent-session.sh
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
#!/usr/bin/env bash
|
||||
# agent-session.sh — Shared tmux + Claude interactive session helpers
|
||||
#
|
||||
# Source this into agent orchestrator scripts for reusable session management.
|
||||
#
|
||||
# Functions:
|
||||
# agent_wait_for_claude_ready SESSION_NAME [TIMEOUT_SECS]
|
||||
# agent_inject_into_session SESSION_NAME TEXT
|
||||
# agent_kill_session SESSION_NAME
|
||||
# monitor_phase_loop PHASE_FILE IDLE_TIMEOUT_SECS CALLBACK_FN [SESSION_NAME]
|
||||
# session_lock_acquire [TIMEOUT_SECS]
|
||||
# session_lock_release
|
||||
|
||||
# --- Cooperative session lock (fd-based) ---
|
||||
# File descriptor for the session lock. Set by create_agent_session().
|
||||
# Callers can release/re-acquire via session_lock_release/session_lock_acquire
|
||||
# to allow other Claude sessions during idle phases (awaiting_review/awaiting_ci).
|
||||
SESSION_LOCK_FD=""
|
||||
|
||||
# Release the session lock without closing the file descriptor.
|
||||
# The fd stays open so it can be re-acquired later.
|
||||
session_lock_release() {
|
||||
if [ -n "${SESSION_LOCK_FD:-}" ]; then
|
||||
flock -u "$SESSION_LOCK_FD"
|
||||
fi
|
||||
}
|
||||
|
||||
# Re-acquire the session lock. Blocks until available or timeout.
|
||||
# Opens the lock fd if not already open (for use by external callers).
|
||||
# Args: [timeout_secs] (default 300)
|
||||
# Returns 0 on success, 1 on timeout/error.
|
||||
# shellcheck disable=SC2120 # timeout arg is used by external callers
|
||||
session_lock_acquire() {
|
||||
local timeout="${1:-300}"
|
||||
if [ -z "${SESSION_LOCK_FD:-}" ]; then
|
||||
local lock_dir="${HOME}/.claude"
|
||||
mkdir -p "$lock_dir"
|
||||
exec {SESSION_LOCK_FD}>>"${lock_dir}/session.lock"
|
||||
fi
|
||||
flock -w "$timeout" "$SESSION_LOCK_FD"
|
||||
}
|
||||
|
||||
# Wait for the Claude ❯ ready prompt in a tmux pane.
|
||||
# Returns 0 if ready within TIMEOUT_SECS (default 120), 1 otherwise.
|
||||
agent_wait_for_claude_ready() {
|
||||
local session="$1"
|
||||
local timeout="${2:-120}"
|
||||
local elapsed=0
|
||||
while [ "$elapsed" -lt "$timeout" ]; do
|
||||
if tmux capture-pane -t "$session" -p 2>/dev/null | grep -q '❯'; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Paste TEXT into SESSION (waits for Claude to be ready first), then press Enter.
|
||||
agent_inject_into_session() {
|
||||
local session="$1"
|
||||
local text="$2"
|
||||
local tmpfile
|
||||
# Re-acquire session lock before injecting — Claude will resume working
|
||||
# shellcheck disable=SC2119 # using default timeout
|
||||
session_lock_acquire || true
|
||||
agent_wait_for_claude_ready "$session" 120 || true
|
||||
# Clear idle marker — new work incoming
|
||||
rm -f "/tmp/claude-idle-${session}.ts"
|
||||
tmpfile=$(mktemp /tmp/agent-inject-XXXXXX)
|
||||
printf '%s' "$text" > "$tmpfile"
|
||||
tmux load-buffer -b "agent-inject-$$" "$tmpfile"
|
||||
tmux paste-buffer -t "$session" -b "agent-inject-$$"
|
||||
sleep 0.5
|
||||
tmux send-keys -t "$session" "" Enter
|
||||
tmux delete-buffer -b "agent-inject-$$" 2>/dev/null || true
|
||||
rm -f "$tmpfile"
|
||||
}
|
||||
|
||||
# Create a tmux session running Claude in the given workdir.
|
||||
# Installs a Stop hook for idle detection (see monitor_phase_loop).
|
||||
# Installs a PreToolUse hook to guard destructive Bash operations.
|
||||
# Optionally installs a PostToolUse hook for phase file write detection.
|
||||
# Optionally installs a StopFailure hook for immediate phase file update on API error.
|
||||
# Args: session workdir [phase_file]
|
||||
# Returns 0 if session is ready, 1 otherwise.
|
||||
create_agent_session() {
|
||||
local session="$1"
|
||||
local workdir="${2:-.}"
|
||||
local phase_file="${3:-}"
|
||||
|
||||
# Prepare settings directory for hooks
|
||||
mkdir -p "${workdir}/.claude"
|
||||
local settings="${workdir}/.claude/settings.json"
|
||||
|
||||
# Install Stop hook for idle detection: when Claude finishes a response,
|
||||
# the hook writes a timestamp to a marker file. monitor_phase_loop checks
|
||||
# this marker instead of fragile tmux pane scraping.
|
||||
local idle_marker="/tmp/claude-idle-${session}.ts"
|
||||
local hook_script="${FACTORY_ROOT}/lib/hooks/on-idle-stop.sh"
|
||||
if [ -x "$hook_script" ]; then
|
||||
local hook_cmd="${hook_script} ${idle_marker}"
|
||||
# When a phase file is available, pass it and the session name so the
|
||||
# hook can nudge Claude if it returns to the prompt without signalling.
|
||||
if [ -n "$phase_file" ]; then
|
||||
hook_cmd="${hook_script} ${idle_marker} ${phase_file} ${session}"
|
||||
fi
|
||||
if [ -f "$settings" ]; then
|
||||
# Append our Stop hook to existing project settings
|
||||
jq --arg cmd "$hook_cmd" '
|
||||
if (.hooks.Stop // [] | any(.[]; .hooks[]?.command == $cmd))
|
||||
then .
|
||||
else .hooks.Stop = (.hooks.Stop // []) + [{
|
||||
matcher: "",
|
||||
hooks: [{type: "command", command: $cmd}]
|
||||
}]
|
||||
end
|
||||
' "$settings" > "${settings}.tmp" && mv "${settings}.tmp" "$settings"
|
||||
else
|
||||
jq -n --arg cmd "$hook_cmd" '{
|
||||
hooks: {
|
||||
Stop: [{
|
||||
matcher: "",
|
||||
hooks: [{type: "command", command: $cmd}]
|
||||
}]
|
||||
}
|
||||
}' > "$settings"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Install PostToolUse hook for phase file write detection: when Claude
|
||||
# writes to the phase file via Bash or Write, the hook writes a marker
|
||||
# so monitor_phase_loop can react immediately instead of waiting for
|
||||
# the next mtime-based poll cycle.
|
||||
if [ -n "$phase_file" ]; then
|
||||
local phase_marker="/tmp/phase-changed-${session}.marker"
|
||||
local phase_hook_script="${FACTORY_ROOT}/lib/hooks/on-phase-change.sh"
|
||||
if [ -x "$phase_hook_script" ]; then
|
||||
local phase_hook_cmd="${phase_hook_script} ${phase_file} ${phase_marker}"
|
||||
if [ -f "$settings" ]; then
|
||||
jq --arg cmd "$phase_hook_cmd" '
|
||||
if (.hooks.PostToolUse // [] | any(.[]; .hooks[]?.command == $cmd))
|
||||
then .
|
||||
else .hooks.PostToolUse = (.hooks.PostToolUse // []) + [{
|
||||
matcher: "Bash|Write",
|
||||
hooks: [{type: "command", command: $cmd}]
|
||||
}]
|
||||
end
|
||||
' "$settings" > "${settings}.tmp" && mv "${settings}.tmp" "$settings"
|
||||
else
|
||||
jq -n --arg cmd "$phase_hook_cmd" '{
|
||||
hooks: {
|
||||
PostToolUse: [{
|
||||
matcher: "Bash|Write",
|
||||
hooks: [{type: "command", command: $cmd}]
|
||||
}]
|
||||
}
|
||||
}' > "$settings"
|
||||
fi
|
||||
rm -f "$phase_marker"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Install StopFailure hook for immediate phase file update on API error:
|
||||
# when Claude hits a rate limit, server error, billing error, or auth failure,
|
||||
# the hook writes PHASE:failed to the phase file and touches the phase-changed
|
||||
# marker so monitor_phase_loop picks it up within one poll cycle instead of
|
||||
# waiting for idle timeout (up to 2 hours).
|
||||
if [ -n "$phase_file" ]; then
|
||||
local stop_failure_hook_script="${FACTORY_ROOT}/lib/hooks/on-stop-failure.sh"
|
||||
if [ -x "$stop_failure_hook_script" ]; then
|
||||
# phase_marker is defined in the PostToolUse block above; redeclare so
|
||||
# this block is self-contained if that block is ever removed.
|
||||
local sf_phase_marker="/tmp/phase-changed-${session}.marker"
|
||||
local stop_failure_hook_cmd="${stop_failure_hook_script} ${phase_file} ${sf_phase_marker}"
|
||||
if [ -f "$settings" ]; then
|
||||
jq --arg cmd "$stop_failure_hook_cmd" '
|
||||
if (.hooks.StopFailure // [] | any(.[]; .hooks[]?.command == $cmd))
|
||||
then .
|
||||
else .hooks.StopFailure = (.hooks.StopFailure // []) + [{
|
||||
matcher: "rate_limit|server_error|authentication_failed|billing_error",
|
||||
hooks: [{type: "command", command: $cmd}]
|
||||
}]
|
||||
end
|
||||
' "$settings" > "${settings}.tmp" && mv "${settings}.tmp" "$settings"
|
||||
else
|
||||
jq -n --arg cmd "$stop_failure_hook_cmd" '{
|
||||
hooks: {
|
||||
StopFailure: [{
|
||||
matcher: "rate_limit|server_error|authentication_failed|billing_error",
|
||||
hooks: [{type: "command", command: $cmd}]
|
||||
}]
|
||||
}
|
||||
}' > "$settings"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Install PreToolUse hook for destructive operation guard: blocks force push
|
||||
# to primary branch, rm -rf outside worktree, direct API merge calls, and
|
||||
# checkout/switch to primary branch. Claude sees the denial reason on exit 2
|
||||
# and can self-correct.
|
||||
local guard_hook_script="${FACTORY_ROOT}/lib/hooks/on-pretooluse-guard.sh"
|
||||
if [ -x "$guard_hook_script" ]; then
|
||||
local abs_workdir
|
||||
abs_workdir=$(cd "$workdir" 2>/dev/null && pwd) || abs_workdir="$workdir"
|
||||
local guard_hook_cmd="${guard_hook_script} ${PRIMARY_BRANCH:-main} ${abs_workdir} ${session}"
|
||||
if [ -f "$settings" ]; then
|
||||
jq --arg cmd "$guard_hook_cmd" '
|
||||
if (.hooks.PreToolUse // [] | any(.[]; .hooks[]?.command == $cmd))
|
||||
then .
|
||||
else .hooks.PreToolUse = (.hooks.PreToolUse // []) + [{
|
||||
matcher: "Bash",
|
||||
hooks: [{type: "command", command: $cmd}]
|
||||
}]
|
||||
end
|
||||
' "$settings" > "${settings}.tmp" && mv "${settings}.tmp" "$settings"
|
||||
else
|
||||
jq -n --arg cmd "$guard_hook_cmd" '{
|
||||
hooks: {
|
||||
PreToolUse: [{
|
||||
matcher: "Bash",
|
||||
hooks: [{type: "command", command: $cmd}]
|
||||
}]
|
||||
}
|
||||
}' > "$settings"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Install SessionEnd hook for guaranteed cleanup: when the Claude session
|
||||
# exits (clean or crash), write a termination marker so monitor_phase_loop
|
||||
# detects the exit faster than tmux has-session polling alone.
|
||||
local exit_marker="/tmp/claude-exited-${session}.ts"
|
||||
local session_end_hook_script="${FACTORY_ROOT}/lib/hooks/on-session-end.sh"
|
||||
if [ -x "$session_end_hook_script" ]; then
|
||||
local session_end_hook_cmd="${session_end_hook_script} ${exit_marker}"
|
||||
if [ -f "$settings" ]; then
|
||||
jq --arg cmd "$session_end_hook_cmd" '
|
||||
if (.hooks.SessionEnd // [] | any(.[]; .hooks[]?.command == $cmd))
|
||||
then .
|
||||
else .hooks.SessionEnd = (.hooks.SessionEnd // []) + [{
|
||||
matcher: "",
|
||||
hooks: [{type: "command", command: $cmd}]
|
||||
}]
|
||||
end
|
||||
' "$settings" > "${settings}.tmp" && mv "${settings}.tmp" "$settings"
|
||||
else
|
||||
jq -n --arg cmd "$session_end_hook_cmd" '{
|
||||
hooks: {
|
||||
SessionEnd: [{
|
||||
matcher: "",
|
||||
hooks: [{type: "command", command: $cmd}]
|
||||
}]
|
||||
}
|
||||
}' > "$settings"
|
||||
fi
|
||||
fi
|
||||
rm -f "$exit_marker"
|
||||
|
||||
# Install SessionStart hook for context re-injection after compaction:
|
||||
# when Claude Code compacts context during long sessions, the phase protocol
|
||||
# instructions are lost. This hook fires after each compaction and outputs
|
||||
# the content of a context file so Claude retains critical instructions.
|
||||
# The context file is written by callers via write_compact_context().
|
||||
if [ -n "$phase_file" ]; then
|
||||
local compact_hook_script="${FACTORY_ROOT}/lib/hooks/on-compact-reinject.sh"
|
||||
if [ -x "$compact_hook_script" ]; then
|
||||
local context_file="${phase_file%.phase}.context"
|
||||
local compact_hook_cmd="${compact_hook_script} ${context_file}"
|
||||
if [ -f "$settings" ]; then
|
||||
jq --arg cmd "$compact_hook_cmd" '
|
||||
if (.hooks.SessionStart // [] | any(.[]; .hooks[]?.command == $cmd))
|
||||
then .
|
||||
else .hooks.SessionStart = (.hooks.SessionStart // []) + [{
|
||||
matcher: "compact",
|
||||
hooks: [{type: "command", command: $cmd}]
|
||||
}]
|
||||
end
|
||||
' "$settings" > "${settings}.tmp" && mv "${settings}.tmp" "$settings"
|
||||
else
|
||||
jq -n --arg cmd "$compact_hook_cmd" '{
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
matcher: "compact",
|
||||
hooks: [{type: "command", command: $cmd}]
|
||||
}]
|
||||
}
|
||||
}' > "$settings"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
rm -f "$idle_marker"
|
||||
local model_flag=""
|
||||
if [ -n "${CLAUDE_MODEL:-}" ]; then
|
||||
model_flag="--model ${CLAUDE_MODEL}"
|
||||
fi
|
||||
|
||||
# Acquire a session-level mutex via fd-based flock to prevent concurrent
|
||||
# Claude sessions from racing on OAuth token refresh. Unlike the previous
|
||||
# command-wrapper flock, the fd approach allows callers to release the lock
|
||||
# during idle phases (awaiting_review/awaiting_ci) and re-acquire before
|
||||
# injecting the next prompt. See #724.
|
||||
# Use ~/.claude/session.lock so the lock is shared across containers when
|
||||
# the host ~/.claude directory is bind-mounted.
|
||||
local lock_dir="${HOME}/.claude"
|
||||
mkdir -p "$lock_dir"
|
||||
local claude_lock="${lock_dir}/session.lock"
|
||||
if [ -z "${SESSION_LOCK_FD:-}" ]; then
|
||||
exec {SESSION_LOCK_FD}>>"${claude_lock}"
|
||||
fi
|
||||
if ! flock -w 300 "$SESSION_LOCK_FD"; then
|
||||
return 1
|
||||
fi
|
||||
local claude_cmd="claude --dangerously-skip-permissions ${model_flag}"
|
||||
|
||||
tmux new-session -d -s "$session" -c "$workdir" \
|
||||
"$claude_cmd" 2>/dev/null
|
||||
sleep 1
|
||||
tmux has-session -t "$session" 2>/dev/null || return 1
|
||||
agent_wait_for_claude_ready "$session" 120 || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# Inject a prompt/formula into a session (alias for agent_inject_into_session).
|
||||
inject_formula() {
|
||||
agent_inject_into_session "$@"
|
||||
}
|
||||
|
||||
# Monitor a phase file, calling a callback on changes and handling idle timeout.
|
||||
# Sets _MONITOR_LOOP_EXIT to the exit reason (idle_timeout, idle_prompt, done, crashed, PHASE:failed, PHASE:escalate).
|
||||
# Sets _MONITOR_SESSION to the resolved session name (arg 4 or $SESSION_NAME).
|
||||
# Callbacks should reference _MONITOR_SESSION instead of $SESSION_NAME directly.
|
||||
# Args: phase_file idle_timeout_secs callback_fn [session_name]
|
||||
# session_name — tmux session to health-check; falls back to $SESSION_NAME global
|
||||
#
|
||||
# Idle detection: uses a Stop hook marker file (written by lib/hooks/on-idle-stop.sh)
|
||||
# to detect when Claude finishes responding without writing a phase signal.
|
||||
# If the marker exists for 3 consecutive polls with no phase written, the session
|
||||
# is killed and the callback invoked with "PHASE:failed".
|
||||
monitor_phase_loop() {
|
||||
local phase_file="$1"
|
||||
local idle_timeout="$2"
|
||||
local callback="$3"
|
||||
local _session="${4:-${SESSION_NAME:-}}"
|
||||
# Export resolved session name so callbacks can reference it regardless of
|
||||
# which session was passed to monitor_phase_loop (analogous to _MONITOR_LOOP_EXIT).
|
||||
export _MONITOR_SESSION="$_session"
|
||||
local poll_interval="${PHASE_POLL_INTERVAL:-10}"
|
||||
local last_mtime=0
|
||||
local idle_elapsed=0
|
||||
local idle_pane_count=0
|
||||
|
||||
while true; do
|
||||
sleep "$poll_interval"
|
||||
idle_elapsed=$(( idle_elapsed + poll_interval ))
|
||||
|
||||
# Session health check: SessionEnd hook marker provides fast detection,
|
||||
# tmux has-session is the fallback for unclean exits (e.g. tmux crash).
|
||||
local exit_marker="/tmp/claude-exited-${_session}.ts"
|
||||
if [ -f "$exit_marker" ] || ! tmux has-session -t "${_session}" 2>/dev/null; then
|
||||
local current_phase
|
||||
current_phase=$(head -1 "$phase_file" 2>/dev/null | tr -d '[:space:]' || true)
|
||||
case "$current_phase" in
|
||||
PHASE:done|PHASE:failed|PHASE:merged|PHASE:escalate)
|
||||
;; # terminal — fall through to phase handler
|
||||
*)
|
||||
# Call callback with "crashed" — let agent-specific code handle recovery
|
||||
if type "${callback}" &>/dev/null; then
|
||||
"$callback" "PHASE:crashed"
|
||||
fi
|
||||
# If callback didn't restart session, break
|
||||
if ! tmux has-session -t "${_session}" 2>/dev/null; then
|
||||
_MONITOR_LOOP_EXIT="crashed"
|
||||
return 1
|
||||
fi
|
||||
idle_elapsed=0
|
||||
idle_pane_count=0
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Check phase-changed marker from PostToolUse hook — if present, the hook
|
||||
# detected a phase file write so we reset last_mtime to force processing
|
||||
# this cycle instead of waiting for the next mtime change.
|
||||
local phase_marker="/tmp/phase-changed-${_session}.marker"
|
||||
if [ -f "$phase_marker" ]; then
|
||||
rm -f "$phase_marker"
|
||||
last_mtime=0
|
||||
fi
|
||||
|
||||
# Check phase file for changes
|
||||
local phase_mtime
|
||||
phase_mtime=$(stat -c %Y "$phase_file" 2>/dev/null || echo 0)
|
||||
local current_phase
|
||||
current_phase=$(head -1 "$phase_file" 2>/dev/null | tr -d '[:space:]' || true)
|
||||
|
||||
if [ -z "$current_phase" ] || [ "$phase_mtime" -le "$last_mtime" ]; then
|
||||
# No phase change — check idle timeout
|
||||
if [ "$idle_elapsed" -ge "$idle_timeout" ]; then
|
||||
_MONITOR_LOOP_EXIT="idle_timeout"
|
||||
agent_kill_session "${_session}"
|
||||
return 0
|
||||
fi
|
||||
# Idle detection via Stop hook: the on-idle-stop.sh hook writes a marker
|
||||
# file when Claude finishes a response. If the marker exists and no phase
|
||||
# has been written, Claude returned to the prompt without following the
|
||||
# phase protocol. 3 consecutive polls = confirmed idle (not mid-turn).
|
||||
local idle_marker="/tmp/claude-idle-${_session}.ts"
|
||||
if [ -z "$current_phase" ] && [ -f "$idle_marker" ]; then
|
||||
idle_pane_count=$(( idle_pane_count + 1 ))
|
||||
if [ "$idle_pane_count" -ge 3 ]; then
|
||||
_MONITOR_LOOP_EXIT="idle_prompt"
|
||||
# Session is killed before the callback is invoked.
|
||||
# Callbacks that handle PHASE:failed must not assume the session is alive.
|
||||
agent_kill_session "${_session}"
|
||||
if type "${callback}" &>/dev/null; then
|
||||
"$callback" "PHASE:failed"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
idle_pane_count=0
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
# Phase changed
|
||||
last_mtime="$phase_mtime"
|
||||
# shellcheck disable=SC2034 # read by phase-handler.sh callback
|
||||
LAST_PHASE_MTIME="$phase_mtime"
|
||||
idle_elapsed=0
|
||||
idle_pane_count=0
|
||||
|
||||
# Terminal phases
|
||||
case "$current_phase" in
|
||||
PHASE:done|PHASE:merged)
|
||||
_MONITOR_LOOP_EXIT="done"
|
||||
if type "${callback}" &>/dev/null; then
|
||||
"$callback" "$current_phase"
|
||||
fi
|
||||
return 0
|
||||
;;
|
||||
PHASE:failed|PHASE:escalate)
|
||||
_MONITOR_LOOP_EXIT="$current_phase"
|
||||
if type "${callback}" &>/dev/null; then
|
||||
"$callback" "$current_phase"
|
||||
fi
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# Non-terminal phase — call callback
|
||||
if type "${callback}" &>/dev/null; then
|
||||
"$callback" "$current_phase"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Write context to a file for re-injection after context compaction.
|
||||
# The SessionStart compact hook reads this file and outputs it to stdout.
|
||||
# Args: phase_file content
|
||||
write_compact_context() {
|
||||
local phase_file="$1"
|
||||
local content="$2"
|
||||
local context_file="${phase_file%.phase}.context"
|
||||
printf '%s\n' "$content" > "$context_file"
|
||||
}
|
||||
|
||||
# Kill a tmux session gracefully (no-op if not found).
|
||||
agent_kill_session() {
|
||||
local session="${1:-}"
|
||||
[ -n "$session" ] && tmux kill-session -t "$session" 2>/dev/null || true
|
||||
rm -f "/tmp/claude-idle-${session}.ts"
|
||||
rm -f "/tmp/phase-changed-${session}.marker"
|
||||
rm -f "/tmp/claude-exited-${session}.ts"
|
||||
rm -f "/tmp/claude-nudge-${session}.count"
|
||||
}
|
||||
|
||||
# Read the current phase from a phase file, stripped of whitespace.
|
||||
# Usage: read_phase [file] — defaults to $PHASE_FILE
|
||||
read_phase() {
|
||||
local file="${1:-${PHASE_FILE:-}}"
|
||||
{ cat "$file" 2>/dev/null || true; } | head -1 | tr -d '[:space:]'
|
||||
}
|
||||
|
|
@ -1,591 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# branch-protection.sh — Helper for setting up branch protection on repos
|
||||
#
|
||||
# Source after lib/env.sh:
|
||||
# source "$(dirname "$0")/../lib/env.sh"
|
||||
# source "$(dirname "$0")/lib/branch-protection.sh"
|
||||
#
|
||||
# Required globals: FORGE_TOKEN, FORGE_URL, FORGE_OPS_REPO
|
||||
#
|
||||
# Functions:
|
||||
# setup_vault_branch_protection — Set up admin-only branch protection for main
|
||||
# verify_branch_protection — Verify protection is configured correctly
|
||||
# setup_profile_branch_protection — Set up admin-only branch protection for .profile repos
|
||||
# remove_branch_protection — Remove branch protection (for cleanup/testing)
|
||||
#
|
||||
# Branch protection settings:
|
||||
# - Require 1 approval before merge
|
||||
# - Restrict merge to admin role (not regular collaborators or bots)
|
||||
# - Block direct pushes to main (all changes must go through PR)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Internal log helper
|
||||
_bp_log() {
|
||||
if declare -f log >/dev/null 2>&1; then
|
||||
log "branch-protection: $*"
|
||||
else
|
||||
printf '[%s] branch-protection: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# Get ops repo API URL
|
||||
_ops_api() {
|
||||
printf '%s' "${FORGE_URL}/api/v1/repos/${FORGE_OPS_REPO}"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# setup_vault_branch_protection — Set up admin-only branch protection for main
|
||||
#
|
||||
# Configures the following protection rules:
|
||||
# - Require 1 approval before merge
|
||||
# - Restrict merge to admin role (not regular collaborators or bots)
|
||||
# - Block direct pushes to main (all changes must go through PR)
|
||||
#
|
||||
# Returns: 0 on success, 1 on failure
|
||||
# -----------------------------------------------------------------------------
|
||||
setup_vault_branch_protection() {
|
||||
local branch="${1:-main}"
|
||||
local api_url
|
||||
api_url="$(_ops_api)"
|
||||
|
||||
_bp_log "Setting up branch protection for ${branch} on ${FORGE_OPS_REPO}"
|
||||
|
||||
# Check if branch exists with retry loop (handles race condition after initial push)
|
||||
local branch_exists="0"
|
||||
local max_attempts=3
|
||||
local attempt=1
|
||||
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
branch_exists=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${api_url}/git/branches/${branch}" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$branch_exists" = "200" ]; then
|
||||
_bp_log "Branch ${branch} exists on ${FORGE_OPS_REPO}"
|
||||
break
|
||||
fi
|
||||
|
||||
if [ "$attempt" -lt "$max_attempts" ]; then
|
||||
_bp_log "Branch ${branch} not indexed yet (attempt ${attempt}/${max_attempts}), waiting 2s..."
|
||||
sleep 2
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
if [ "$branch_exists" != "200" ]; then
|
||||
_bp_log "ERROR: Branch ${branch} does not exist on ${FORGE_OPS_REPO} after ${max_attempts} attempts"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if protection already exists
|
||||
local protection_exists
|
||||
protection_exists=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${api_url}/branches/${branch}/protection" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$protection_exists" = "200" ]; then
|
||||
_bp_log "Branch protection already exists for ${branch}"
|
||||
_bp_log "Updating existing protection rules"
|
||||
fi
|
||||
|
||||
# Create/update branch protection
|
||||
# Note: Forgejo API uses "require_signed_commits" and "required_approvals" for approval requirements
|
||||
# The "admin_enforced" field ensures only admins can merge
|
||||
local protection_json
|
||||
protection_json=$(cat <<EOF
|
||||
{
|
||||
"enable_push": false,
|
||||
"enable_force_push": false,
|
||||
"enable_merge_commit": true,
|
||||
"enable_rebase": true,
|
||||
"enable_rebase_merge": true,
|
||||
"required_approvals": 1,
|
||||
"required_signatures": false,
|
||||
"admin_enforced": true,
|
||||
"required_status_checks": false,
|
||||
"required_linear_history": false
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
local http_code
|
||||
if [ "$protection_exists" = "200" ]; then
|
||||
# Update existing protection
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X PUT \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${api_url}/branches/${branch}/protection" \
|
||||
-d "$protection_json" || echo "0")
|
||||
else
|
||||
# Create new protection
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${api_url}/branches/${branch}/protection" \
|
||||
-d "$protection_json" || echo "0")
|
||||
fi
|
||||
|
||||
if [ "$http_code" != "200" ] && [ "$http_code" != "201" ]; then
|
||||
_bp_log "ERROR: Failed to set up branch protection (HTTP ${http_code})"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_bp_log "Branch protection configured successfully for ${branch}"
|
||||
_bp_log " - Pushes blocked: true"
|
||||
_bp_log " - Force pushes blocked: true"
|
||||
_bp_log " - Required approvals: 1"
|
||||
_bp_log " - Admin enforced: true"
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# verify_branch_protection — Verify protection is configured correctly
|
||||
#
|
||||
# Returns: 0 if protection is configured correctly, 1 otherwise
|
||||
# -----------------------------------------------------------------------------
|
||||
verify_branch_protection() {
|
||||
local branch="${1:-main}"
|
||||
local api_url
|
||||
api_url="$(_ops_api)"
|
||||
|
||||
_bp_log "Verifying branch protection for ${branch}"
|
||||
|
||||
# Get current protection settings
|
||||
local protection_json
|
||||
protection_json=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${api_url}/branches/${branch}/protection" 2>/dev/null || true)
|
||||
|
||||
if [ -z "$protection_json" ] || [ "$protection_json" = "null" ]; then
|
||||
_bp_log "ERROR: No branch protection found for ${branch}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Extract and validate settings
|
||||
local enable_push enable_merge_commit required_approvals admin_enforced
|
||||
enable_push=$(printf '%s' "$protection_json" | jq -r '.enable_push // true')
|
||||
enable_merge_commit=$(printf '%s' "$protection_json" | jq -r '.enable_merge_commit // false')
|
||||
required_approvals=$(printf '%s' "$protection_json" | jq -r '.required_approvals // 0')
|
||||
admin_enforced=$(printf '%s' "$protection_json" | jq -r '.admin_enforced // false')
|
||||
|
||||
local errors=0
|
||||
|
||||
# Check push is disabled
|
||||
if [ "$enable_push" = "true" ]; then
|
||||
_bp_log "ERROR: enable_push should be false"
|
||||
errors=$((errors + 1))
|
||||
else
|
||||
_bp_log "OK: Pushes are blocked"
|
||||
fi
|
||||
|
||||
# Check merge commit is enabled
|
||||
if [ "$enable_merge_commit" != "true" ]; then
|
||||
_bp_log "ERROR: enable_merge_commit should be true"
|
||||
errors=$((errors + 1))
|
||||
else
|
||||
_bp_log "OK: Merge commits are allowed"
|
||||
fi
|
||||
|
||||
# Check required approvals
|
||||
if [ "$required_approvals" -lt 1 ]; then
|
||||
_bp_log "ERROR: required_approvals should be at least 1"
|
||||
errors=$((errors + 1))
|
||||
else
|
||||
_bp_log "OK: Required approvals: ${required_approvals}"
|
||||
fi
|
||||
|
||||
# Check admin enforced
|
||||
if [ "$admin_enforced" != "true" ]; then
|
||||
_bp_log "ERROR: admin_enforced should be true"
|
||||
errors=$((errors + 1))
|
||||
else
|
||||
_bp_log "OK: Admin enforcement enabled"
|
||||
fi
|
||||
|
||||
if [ "$errors" -gt 0 ]; then
|
||||
_bp_log "Verification failed with ${errors} error(s)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_bp_log "Branch protection verified successfully"
|
||||
return 0
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# setup_profile_branch_protection — Set up admin-only branch protection for .profile repos
|
||||
#
|
||||
# Configures the following protection rules:
|
||||
# - Require 1 approval before merge
|
||||
# - Restrict merge to admin role (not regular collaborators or bots)
|
||||
# - Block direct pushes to main (all changes must go through PR)
|
||||
#
|
||||
# Also creates a 'journal' branch for direct agent journal pushes
|
||||
#
|
||||
# Args:
|
||||
# $1 - Repo path in format 'owner/repo' (e.g., 'dev-bot/.profile')
|
||||
# $2 - Branch to protect (default: main)
|
||||
#
|
||||
# Returns: 0 on success, 1 on failure
|
||||
# -----------------------------------------------------------------------------
|
||||
setup_profile_branch_protection() {
|
||||
local repo="${1:-}"
|
||||
local branch="${2:-main}"
|
||||
|
||||
if [ -z "$repo" ]; then
|
||||
_bp_log "ERROR: repo path required (format: owner/repo)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_bp_log "Setting up branch protection for ${branch} on ${repo}"
|
||||
|
||||
local api_url
|
||||
api_url="${FORGE_URL}/api/v1/repos/${repo}"
|
||||
|
||||
# Check if branch exists with retry loop (handles race condition after initial push)
|
||||
local branch_exists="0"
|
||||
local max_attempts=3
|
||||
local attempt=1
|
||||
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
branch_exists=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${api_url}/git/branches/${branch}" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$branch_exists" = "200" ]; then
|
||||
_bp_log "Branch ${branch} exists on ${repo}"
|
||||
break
|
||||
fi
|
||||
|
||||
if [ "$attempt" -lt "$max_attempts" ]; then
|
||||
_bp_log "Branch ${branch} not indexed yet (attempt ${attempt}/${max_attempts}), waiting 2s..."
|
||||
sleep 2
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
if [ "$branch_exists" != "200" ]; then
|
||||
_bp_log "ERROR: Branch ${branch} does not exist on ${repo} after ${max_attempts} attempts"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if protection already exists
|
||||
local protection_exists
|
||||
protection_exists=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${api_url}/branches/${branch}/protection" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$protection_exists" = "200" ]; then
|
||||
_bp_log "Branch protection already exists for ${branch}"
|
||||
_bp_log "Updating existing protection rules"
|
||||
fi
|
||||
|
||||
# Create/update branch protection
|
||||
local protection_json
|
||||
protection_json=$(cat <<EOF
|
||||
{
|
||||
"enable_push": false,
|
||||
"enable_force_push": false,
|
||||
"enable_merge_commit": true,
|
||||
"enable_rebase": true,
|
||||
"enable_rebase_merge": true,
|
||||
"required_approvals": 1,
|
||||
"required_signatures": false,
|
||||
"admin_enforced": true,
|
||||
"required_status_checks": false,
|
||||
"required_linear_history": false
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
local http_code
|
||||
if [ "$protection_exists" = "200" ]; then
|
||||
# Update existing protection
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X PUT \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${api_url}/branches/${branch}/protection" \
|
||||
-d "$protection_json" || echo "0")
|
||||
else
|
||||
# Create new protection
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${api_url}/branches/${branch}/protection" \
|
||||
-d "$protection_json" || echo "0")
|
||||
fi
|
||||
|
||||
if [ "$http_code" != "200" ] && [ "$http_code" != "201" ]; then
|
||||
_bp_log "ERROR: Failed to set up branch protection (HTTP ${http_code})"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_bp_log "Branch protection configured successfully for ${branch}"
|
||||
_bp_log " - Pushes blocked: true"
|
||||
_bp_log " - Force pushes blocked: true"
|
||||
_bp_log " - Required approvals: 1"
|
||||
_bp_log " - Admin enforced: true"
|
||||
|
||||
# Create journal branch for direct agent journal pushes
|
||||
_bp_log "Creating 'journal' branch for direct agent journal pushes"
|
||||
|
||||
local journal_branch="journal"
|
||||
local journal_exists
|
||||
journal_exists=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${api_url}/git/branches/${journal_branch}" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$journal_exists" != "200" ]; then
|
||||
# Create journal branch from main
|
||||
# Get the commit hash of main
|
||||
local main_commit
|
||||
main_commit=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${api_url}/git/refs/heads/${branch}" 2>/dev/null | jq -r '.[0].object.sha' || echo "")
|
||||
|
||||
if [ -n "$main_commit" ]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${api_url}/git/refs" \
|
||||
-d "{\"ref\":\"refs/heads/${journal_branch}\",\"sha\":\"${main_commit}\"}" >/dev/null 2>&1 || {
|
||||
_bp_log "Warning: failed to create journal branch (may already exist)"
|
||||
}
|
||||
fi
|
||||
fi
|
||||
|
||||
_bp_log "Journal branch '${journal_branch}' ready for direct pushes"
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# remove_branch_protection — Remove branch protection (for cleanup/testing)
|
||||
#
|
||||
# Returns: 0 on success, 1 on failure
|
||||
# -----------------------------------------------------------------------------
|
||||
remove_branch_protection() {
|
||||
local branch="${1:-main}"
|
||||
local api_url
|
||||
api_url="$(_ops_api)"
|
||||
|
||||
_bp_log "Removing branch protection for ${branch}"
|
||||
|
||||
# Check if protection exists
|
||||
local protection_exists
|
||||
protection_exists=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${api_url}/branches/${branch}/protection" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$protection_exists" != "200" ]; then
|
||||
_bp_log "No branch protection found for ${branch}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Delete protection
|
||||
local http_code
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X DELETE \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${api_url}/branches/${branch}/protection" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$http_code" != "204" ]; then
|
||||
_bp_log "ERROR: Failed to remove branch protection (HTTP ${http_code})"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_bp_log "Branch protection removed successfully for ${branch}"
|
||||
return 0
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# setup_project_branch_protection — Set up branch protection for project repos
|
||||
#
|
||||
# Configures the following protection rules:
|
||||
# - Block direct pushes to main (all changes must go through PR)
|
||||
# - Require 1 approval before merge
|
||||
# - Allow merge only via dev-bot (for auto-merge after review+CI)
|
||||
# - Allow review-bot to approve PRs
|
||||
#
|
||||
# Args:
|
||||
# $1 - Repo path in format 'owner/repo' (e.g., 'disinto-admin/disinto')
|
||||
# $2 - Branch to protect (default: main)
|
||||
#
|
||||
# Returns: 0 on success, 1 on failure
|
||||
# -----------------------------------------------------------------------------
|
||||
setup_project_branch_protection() {
|
||||
local repo="${1:-}"
|
||||
local branch="${2:-main}"
|
||||
|
||||
if [ -z "$repo" ]; then
|
||||
_bp_log "ERROR: repo path required (format: owner/repo)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_bp_log "Setting up branch protection for ${branch} on ${repo}"
|
||||
|
||||
local api_url
|
||||
api_url="${FORGE_URL}/api/v1/repos/${repo}"
|
||||
|
||||
# Check if branch exists with retry loop (handles race condition after initial push)
|
||||
local branch_exists="0"
|
||||
local max_attempts=3
|
||||
local attempt=1
|
||||
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
branch_exists=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${api_url}/git/branches/${branch}" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$branch_exists" = "200" ]; then
|
||||
_bp_log "Branch ${branch} exists on ${repo}"
|
||||
break
|
||||
fi
|
||||
|
||||
if [ "$attempt" -lt "$max_attempts" ]; then
|
||||
_bp_log "Branch ${branch} not indexed yet (attempt ${attempt}/${max_attempts}), waiting 2s..."
|
||||
sleep 2
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
if [ "$branch_exists" != "200" ]; then
|
||||
_bp_log "ERROR: Branch ${branch} does not exist on ${repo} after ${max_attempts} attempts"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if protection already exists
|
||||
local protection_exists
|
||||
protection_exists=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${api_url}/branches/${branch}/protection" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$protection_exists" = "200" ]; then
|
||||
_bp_log "Branch protection already exists for ${branch}"
|
||||
_bp_log "Updating existing protection rules"
|
||||
fi
|
||||
|
||||
# Create/update branch protection
|
||||
# Forgejo API for branch protection (factory mode):
|
||||
# - enable_push: false (block direct pushes)
|
||||
# - enable_merge_whitelist: true (only whitelisted users can merge)
|
||||
# - merge_whitelist_usernames: ["dev-bot"] (dev-bot merges after CI)
|
||||
# - required_approvals: 1 (review-bot must approve)
|
||||
local protection_json
|
||||
protection_json=$(cat <<EOF
|
||||
{
|
||||
"enable_push": false,
|
||||
"enable_force_push": false,
|
||||
"enable_merge_commit": true,
|
||||
"enable_rebase": true,
|
||||
"enable_rebase_merge": true,
|
||||
"required_approvals": 1,
|
||||
"required_signatures": false,
|
||||
"enable_merge_whitelist": true,
|
||||
"merge_whitelist_usernames": ["dev-bot"],
|
||||
"required_status_checks": false,
|
||||
"required_linear_history": false
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
local http_code
|
||||
if [ "$protection_exists" = "200" ]; then
|
||||
# Update existing protection
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X PUT \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${api_url}/branches/${branch}/protection" \
|
||||
-d "$protection_json" || echo "0")
|
||||
else
|
||||
# Create new protection
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${api_url}/branches/${branch}/protection" \
|
||||
-d "$protection_json" || echo "0")
|
||||
fi
|
||||
|
||||
if [ "$http_code" != "200" ] && [ "$http_code" != "201" ]; then
|
||||
_bp_log "ERROR: Failed to set up branch protection (HTTP ${http_code})"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_bp_log "Branch protection configured successfully for ${branch}"
|
||||
_bp_log " - Pushes blocked: true"
|
||||
_bp_log " - Force pushes blocked: true"
|
||||
_bp_log " - Required approvals: 1"
|
||||
_bp_log " - Merge whitelist: dev-bot only"
|
||||
_bp_log " - review-bot can approve: yes"
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Test mode — run when executed directly
|
||||
# -----------------------------------------------------------------------------
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
# Check required env vars
|
||||
if [ -z "${FORGE_TOKEN:-}" ]; then
|
||||
echo "ERROR: FORGE_TOKEN is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${FORGE_URL:-}" ]; then
|
||||
echo "ERROR: FORGE_URL is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${FORGE_OPS_REPO:-}" ]; then
|
||||
echo "ERROR: FORGE_OPS_REPO is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse command line args
|
||||
case "${1:-help}" in
|
||||
setup)
|
||||
setup_vault_branch_protection "${2:-main}"
|
||||
;;
|
||||
setup-profile)
|
||||
if [ -z "${2:-}" ]; then
|
||||
echo "ERROR: repo path required (format: owner/repo)" >&2
|
||||
exit 1
|
||||
fi
|
||||
setup_profile_branch_protection "${2}" "${3:-main}"
|
||||
;;
|
||||
setup-project)
|
||||
if [ -z "${2:-}" ]; then
|
||||
echo "ERROR: repo path required (format: owner/repo)" >&2
|
||||
exit 1
|
||||
fi
|
||||
setup_project_branch_protection "${2}" "${3:-main}"
|
||||
;;
|
||||
verify)
|
||||
verify_branch_protection "${2:-main}"
|
||||
;;
|
||||
remove)
|
||||
remove_branch_protection "${2:-main}"
|
||||
;;
|
||||
help|*)
|
||||
echo "Usage: $0 {setup|setup-profile|setup-project|verify|remove} [args...]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " setup [branch] Set up branch protection on ops repo (default: main)"
|
||||
echo " setup-profile <repo> [branch] Set up branch protection on .profile repo"
|
||||
echo " setup-project <repo> [branch] Set up branch protection on project repo"
|
||||
echo " verify [branch] Verify branch protection is configured correctly"
|
||||
echo " remove [branch] Remove branch protection (for cleanup/testing)"
|
||||
echo ""
|
||||
echo "Required environment variables:"
|
||||
echo " FORGE_TOKEN Forgejo API token (admin user recommended)"
|
||||
echo " FORGE_URL Forgejo instance URL (e.g., https://codeberg.org)"
|
||||
echo " FORGE_OPS_REPO Ops repo in format owner/repo (e.g., disinto-admin/disinto-ops)"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
|
@ -17,11 +17,6 @@ REPO="${FORGE_REPO}"
|
|||
API="${WOODPECKER_SERVER}/api/repos/${WOODPECKER_REPO_ID}"
|
||||
|
||||
api() {
|
||||
# Validate API URL to prevent URL injection
|
||||
if ! validate_url "$API"; then
|
||||
echo "ERROR: API URL validation failed - possible URL injection attempt" >&2
|
||||
return 1
|
||||
fi
|
||||
curl -sf -H "Authorization: Bearer ${WOODPECKER_TOKEN}" "${API}/$1"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,27 @@ set -euo pipefail
|
|||
# ci_commit_status() / ci_pipeline_number() require: woodpecker_api(), forge_api() (from env.sh)
|
||||
# classify_pipeline_failure() requires: woodpecker_api() (defined in env.sh)
|
||||
|
||||
# ensure_blocked_label_id — look up (or create) the "blocked" label, print its ID.
|
||||
# Caches the result in _BLOCKED_LABEL_ID to avoid repeated API calls.
|
||||
# Requires: FORGE_TOKEN, FORGE_API (from env.sh), forge_api()
|
||||
ensure_blocked_label_id() {
|
||||
if [ -n "${_BLOCKED_LABEL_ID:-}" ]; then
|
||||
printf '%s' "$_BLOCKED_LABEL_ID"
|
||||
return 0
|
||||
fi
|
||||
_BLOCKED_LABEL_ID=$(forge_api GET "/labels" 2>/dev/null \
|
||||
| jq -r '.[] | select(.name == "blocked") | .id' 2>/dev/null || true)
|
||||
if [ -z "$_BLOCKED_LABEL_ID" ]; then
|
||||
_BLOCKED_LABEL_ID=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/labels" \
|
||||
-d '{"name":"blocked","color":"#e11d48"}' 2>/dev/null \
|
||||
| jq -r '.id // empty' 2>/dev/null || true)
|
||||
fi
|
||||
printf '%s' "$_BLOCKED_LABEL_ID"
|
||||
}
|
||||
|
||||
# ensure_priority_label — look up (or create) the "priority" label, print its ID.
|
||||
# Caches the result in _PRIORITY_LABEL_ID to avoid repeated API calls.
|
||||
# Requires: FORGE_TOKEN, FORGE_API (from env.sh), forge_api()
|
||||
|
|
@ -246,42 +267,3 @@ ci_promote() {
|
|||
|
||||
echo "$new_num"
|
||||
}
|
||||
|
||||
# ci_get_logs <pipeline_number> [--step <step_name>]
|
||||
# Reads CI logs from the Woodpecker SQLite database.
|
||||
# Requires: WOODPECKER_DATA_DIR env var or mounted volume at /woodpecker-data
|
||||
# Returns: 0 on success, 1 on failure. Outputs log text to stdout.
|
||||
#
|
||||
# Usage:
|
||||
# ci_get_logs 346 # Get all failed step logs
|
||||
# ci_get_logs 346 --step smoke-init # Get logs for specific step
|
||||
ci_get_logs() {
|
||||
local pipeline_number="$1"
|
||||
shift || true
|
||||
|
||||
local step_name=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--step|-s)
|
||||
step_name="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
local log_reader="${FACTORY_ROOT:-/home/agent/disinto}/lib/ci-log-reader.py"
|
||||
if [ -f "$log_reader" ]; then
|
||||
if [ -n "$step_name" ]; then
|
||||
python3 "$log_reader" "$pipeline_number" --step "$step_name"
|
||||
else
|
||||
python3 "$log_reader" "$pipeline_number"
|
||||
fi
|
||||
else
|
||||
echo "ERROR: ci-log-reader.py not found at $log_reader" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,125 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
ci-log-reader.py — Read CI logs from Woodpecker SQLite database.
|
||||
|
||||
Usage:
|
||||
ci-log-reader.py <pipeline_number> [--step <step_name>]
|
||||
|
||||
Reads log entries from the Woodpecker SQLite database and outputs them to stdout.
|
||||
If --step is specified, filters to that step only. Otherwise returns logs from
|
||||
all failed steps, truncated to the last 200 lines to avoid context bloat.
|
||||
|
||||
Environment:
|
||||
WOODPECKER_DATA_DIR - Path to Woodpecker data directory (default: /woodpecker-data)
|
||||
|
||||
The SQLite database is located at: $WOODPECKER_DATA_DIR/woodpecker.sqlite
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
import sys
|
||||
import os
|
||||
|
||||
DEFAULT_DB_PATH = "/woodpecker-data/woodpecker.sqlite"
|
||||
DEFAULT_WOODPECKER_DATA_DIR = "/woodpecker-data"
|
||||
MAX_OUTPUT_LINES = 200
|
||||
|
||||
|
||||
def get_db_path():
|
||||
"""Determine the path to the Woodpecker SQLite database."""
|
||||
env_dir = os.environ.get("WOODPECKER_DATA_DIR", DEFAULT_WOODPECKER_DATA_DIR)
|
||||
return os.path.join(env_dir, "woodpecker.sqlite")
|
||||
|
||||
|
||||
def query_logs(pipeline_number: int, step_name: str | None = None) -> list[str]:
|
||||
"""
|
||||
Query log entries from the Woodpecker database.
|
||||
|
||||
Args:
|
||||
pipeline_number: The pipeline number to query
|
||||
step_name: Optional step name to filter by
|
||||
|
||||
Returns:
|
||||
List of log data strings
|
||||
"""
|
||||
db_path = get_db_path()
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f"ERROR: Woodpecker database not found at {db_path}", file=sys.stderr)
|
||||
print(f"Set WOODPECKER_DATA_DIR or mount volume to {DEFAULT_WOODPECKER_DATA_DIR}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
if step_name:
|
||||
# Query logs for a specific step
|
||||
query = """
|
||||
SELECT le.data
|
||||
FROM log_entries le
|
||||
JOIN steps s ON le.step_id = s.id
|
||||
JOIN pipelines p ON s.pipeline_id = p.id
|
||||
WHERE p.number = ? AND s.name = ?
|
||||
ORDER BY le.id
|
||||
"""
|
||||
cursor.execute(query, (pipeline_number, step_name))
|
||||
else:
|
||||
# Query logs for all failed steps in the pipeline
|
||||
query = """
|
||||
SELECT le.data
|
||||
FROM log_entries le
|
||||
JOIN steps s ON le.step_id = s.id
|
||||
JOIN pipelines p ON s.pipeline_id = p.id
|
||||
WHERE p.number = ? AND s.state IN ('failure', 'error', 'killed')
|
||||
ORDER BY le.id
|
||||
"""
|
||||
cursor.execute(query, (pipeline_number,))
|
||||
|
||||
logs = [row["data"] for row in cursor.fetchall()]
|
||||
conn.close()
|
||||
return logs
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Read CI logs from Woodpecker SQLite database"
|
||||
)
|
||||
parser.add_argument(
|
||||
"pipeline_number",
|
||||
type=int,
|
||||
help="Pipeline number to query"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--step", "-s",
|
||||
dest="step_name",
|
||||
default=None,
|
||||
help="Filter to a specific step name"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
logs = query_logs(args.pipeline_number, args.step_name)
|
||||
|
||||
if not logs:
|
||||
if args.step_name:
|
||||
print(f"No logs found for pipeline #{args.pipeline_number}, step '{args.step_name}'", file=sys.stderr)
|
||||
else:
|
||||
print(f"No failed steps found in pipeline #{args.pipeline_number}", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
# Join all log data and output
|
||||
full_output = "\n".join(logs)
|
||||
|
||||
# Truncate to last N lines to avoid context bloat
|
||||
lines = full_output.split("\n")
|
||||
if len(lines) > MAX_OUTPUT_LINES:
|
||||
# Keep last N lines
|
||||
truncated = lines[-MAX_OUTPUT_LINES:]
|
||||
print("\n".join(truncated))
|
||||
else:
|
||||
print(full_output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
455
lib/ci-setup.sh
455
lib/ci-setup.sh
|
|
@ -1,455 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# ci-setup.sh — CI setup functions for Woodpecker and cron configuration
|
||||
#
|
||||
# Internal functions (called via _load_ci_context + _*_impl):
|
||||
# _install_cron_impl() - Install crontab entries for project agents
|
||||
# _create_woodpecker_oauth_impl() - Create OAuth2 app on Forgejo for Woodpecker
|
||||
# _generate_woodpecker_token_impl() - Auto-generate WOODPECKER_TOKEN via OAuth2 flow
|
||||
# _activate_woodpecker_repo_impl() - Activate repo in Woodpecker
|
||||
#
|
||||
# Globals expected (asserted by _load_ci_context):
|
||||
# FORGE_URL - Forge instance URL (e.g. http://localhost:3000)
|
||||
# FORGE_TOKEN - Forge API token
|
||||
# FACTORY_ROOT - Root of the disinto factory
|
||||
#
|
||||
# Usage:
|
||||
# source "${FACTORY_ROOT}/lib/ci-setup.sh"
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# Assert required globals are set before using this module.
|
||||
_load_ci_context() {
|
||||
local missing=()
|
||||
[ -z "${FORGE_URL:-}" ] && missing+=("FORGE_URL")
|
||||
[ -z "${FORGE_TOKEN:-}" ] && missing+=("FORGE_TOKEN")
|
||||
[ -z "${FACTORY_ROOT:-}" ] && missing+=("FACTORY_ROOT")
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
echo "Error: ci-setup.sh requires these globals to be set: ${missing[*]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Generate and optionally install cron entries for the project agents.
|
||||
# Usage: install_cron <name> <toml_path> <auto_yes> <bare>
|
||||
_install_cron_impl() {
|
||||
local name="$1" toml="$2" auto_yes="$3" bare="${4:-false}"
|
||||
|
||||
# In compose mode, skip host cron — the agents container runs cron internally
|
||||
if [ "$bare" = false ]; then
|
||||
echo ""
|
||||
echo "Cron: skipped (agents container handles scheduling in compose mode)"
|
||||
return
|
||||
fi
|
||||
|
||||
# Bare mode: crontab is required on the host
|
||||
if ! command -v crontab &>/dev/null; then
|
||||
echo "Error: crontab not found (required for bare-metal mode)" >&2
|
||||
echo " Install: apt install cron / brew install cron" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Use absolute path for the TOML in cron entries
|
||||
local abs_toml
|
||||
abs_toml="$(cd "$(dirname "$toml")" && pwd)/$(basename "$toml")"
|
||||
|
||||
local cron_block
|
||||
cron_block="# disinto: ${name}
|
||||
2,7,12,17,22,27,32,37,42,47,52,57 * * * * ${FACTORY_ROOT}/review/review-poll.sh ${abs_toml} >/dev/null 2>&1
|
||||
4,9,14,19,24,29,34,39,44,49,54,59 * * * * ${FACTORY_ROOT}/dev/dev-poll.sh ${abs_toml} >/dev/null 2>&1
|
||||
0 0,6,12,18 * * * cd ${FACTORY_ROOT} && bash gardener/gardener-run.sh ${abs_toml} >/dev/null 2>&1"
|
||||
|
||||
echo ""
|
||||
echo "Cron entries to install:"
|
||||
echo "$cron_block"
|
||||
echo ""
|
||||
|
||||
# Check if cron entries already exist
|
||||
local current_crontab
|
||||
current_crontab=$(crontab -l 2>/dev/null || true)
|
||||
if echo "$current_crontab" | grep -q "# disinto: ${name}"; then
|
||||
echo "Cron: skipped (entries for ${name} already installed)"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$auto_yes" = false ] && [ -t 0 ]; then
|
||||
read -rp "Install these cron entries? [y/N] " confirm
|
||||
if [[ ! "$confirm" =~ ^[Yy] ]]; then
|
||||
echo "Skipped cron install. Add manually with: crontab -e"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# Append to existing crontab
|
||||
if { crontab -l 2>/dev/null || true; printf '%s\n' "$cron_block"; } | crontab -; then
|
||||
echo "Cron entries installed for ${name}"
|
||||
else
|
||||
echo "Error: failed to install cron entries" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Set up Woodpecker CI to use Forgejo as its forge backend.
|
||||
# Creates an OAuth2 app on Forgejo for Woodpecker, activates the repo.
|
||||
# Usage: create_woodpecker_oauth <forge_url> <repo_slug>
|
||||
_create_woodpecker_oauth_impl() {
|
||||
local forge_url="$1"
|
||||
local _repo_slug="$2" # unused but required for signature compatibility
|
||||
|
||||
echo ""
|
||||
echo "── Woodpecker OAuth2 setup ────────────────────────────"
|
||||
|
||||
# Create OAuth2 application on Forgejo for Woodpecker
|
||||
local oauth2_name="woodpecker-ci"
|
||||
local redirect_uri="http://localhost:8000/authorize"
|
||||
local existing_app client_id client_secret
|
||||
|
||||
# Check if OAuth2 app already exists
|
||||
existing_app=$(curl -sf \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${forge_url}/api/v1/user/applications/oauth2" 2>/dev/null \
|
||||
| jq -r --arg name "$oauth2_name" '.[] | select(.name == $name) | .client_id // empty' 2>/dev/null) || true
|
||||
|
||||
if [ -n "$existing_app" ]; then
|
||||
echo "OAuth2: ${oauth2_name} (already exists, client_id=${existing_app})"
|
||||
client_id="$existing_app"
|
||||
else
|
||||
local oauth2_resp
|
||||
oauth2_resp=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/user/applications/oauth2" \
|
||||
-d "{\"name\":\"${oauth2_name}\",\"redirect_uris\":[\"${redirect_uri}\"],\"confidential_client\":true}" \
|
||||
2>/dev/null) || oauth2_resp=""
|
||||
|
||||
if [ -z "$oauth2_resp" ]; then
|
||||
echo "Warning: failed to create OAuth2 app on Forgejo" >&2
|
||||
return
|
||||
fi
|
||||
|
||||
client_id=$(printf '%s' "$oauth2_resp" | jq -r '.client_id // empty')
|
||||
client_secret=$(printf '%s' "$oauth2_resp" | jq -r '.client_secret // empty')
|
||||
|
||||
if [ -z "$client_id" ]; then
|
||||
echo "Warning: OAuth2 app creation returned no client_id" >&2
|
||||
return
|
||||
fi
|
||||
|
||||
echo "OAuth2: ${oauth2_name} created (client_id=${client_id})"
|
||||
fi
|
||||
|
||||
# Store Woodpecker forge config in .env
|
||||
# WP_FORGEJO_CLIENT/SECRET match the docker-compose.yml variable references
|
||||
# WOODPECKER_HOST must be host-accessible URL to match OAuth2 redirect_uri
|
||||
local env_file="${FACTORY_ROOT}/.env"
|
||||
local wp_vars=(
|
||||
"WOODPECKER_FORGEJO=true"
|
||||
"WOODPECKER_FORGEJO_URL=${forge_url}"
|
||||
"WOODPECKER_HOST=http://localhost:8000"
|
||||
)
|
||||
if [ -n "${client_id:-}" ]; then
|
||||
wp_vars+=("WP_FORGEJO_CLIENT=${client_id}")
|
||||
fi
|
||||
if [ -n "${client_secret:-}" ]; then
|
||||
wp_vars+=("WP_FORGEJO_SECRET=${client_secret}")
|
||||
fi
|
||||
|
||||
for var_line in "${wp_vars[@]}"; do
|
||||
local var_name="${var_line%%=*}"
|
||||
if grep -q "^${var_name}=" "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^${var_name}=.*|${var_line}|" "$env_file"
|
||||
else
|
||||
printf '%s\n' "$var_line" >> "$env_file"
|
||||
fi
|
||||
done
|
||||
echo "Config: Woodpecker forge vars written to .env"
|
||||
}
|
||||
|
||||
# Auto-generate WOODPECKER_TOKEN by driving the Forgejo OAuth2 login flow.
|
||||
# Requires _FORGE_ADMIN_PASS (set by setup_forge when admin user was just created).
|
||||
# Called after compose stack is up, before activate_woodpecker_repo.
|
||||
# Usage: generate_woodpecker_token <forge_url>
|
||||
_generate_woodpecker_token_impl() {
|
||||
local forge_url="$1"
|
||||
local wp_server="${WOODPECKER_SERVER:-http://localhost:8000}"
|
||||
local env_file="${FACTORY_ROOT}/.env"
|
||||
local admin_user="disinto-admin"
|
||||
local admin_pass="${_FORGE_ADMIN_PASS:-}"
|
||||
|
||||
# Skip if already set
|
||||
if grep -q '^WOODPECKER_TOKEN=' "$env_file" 2>/dev/null; then
|
||||
echo "Config: WOODPECKER_TOKEN already set in .env"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "── Woodpecker token generation ────────────────────────"
|
||||
|
||||
if [ -z "$admin_pass" ]; then
|
||||
echo "Warning: Forgejo admin password not available — cannot generate WOODPECKER_TOKEN" >&2
|
||||
echo " Log into Woodpecker at ${wp_server} and create a token manually" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Wait for Woodpecker to become ready
|
||||
echo -n "Waiting for Woodpecker"
|
||||
local retries=0
|
||||
while ! curl -sf --max-time 3 "${wp_server}/api/version" >/dev/null 2>&1; do
|
||||
retries=$((retries + 1))
|
||||
if [ "$retries" -gt 30 ]; then
|
||||
echo ""
|
||||
echo "Warning: Woodpecker not ready at ${wp_server} — skipping token generation" >&2
|
||||
return 1
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 2
|
||||
done
|
||||
echo " ready"
|
||||
|
||||
# Flow: Forgejo web login → OAuth2 authorize → Woodpecker callback → token
|
||||
local cookie_jar auth_body_file
|
||||
cookie_jar=$(mktemp /tmp/wp-auth-XXXXXX)
|
||||
auth_body_file=$(mktemp /tmp/wp-body-XXXXXX)
|
||||
|
||||
# Step 1: Log into Forgejo web UI (session cookie needed for OAuth consent)
|
||||
local csrf
|
||||
csrf=$(curl -sf -c "$cookie_jar" "${forge_url}/user/login" 2>/dev/null \
|
||||
| grep -o 'name="_csrf"[^>]*' | head -1 \
|
||||
| grep -oE '(content|value)="[^"]*"' | head -1 \
|
||||
| cut -d'"' -f2) || csrf=""
|
||||
|
||||
if [ -z "$csrf" ]; then
|
||||
echo "Warning: could not get Forgejo CSRF token — skipping token generation" >&2
|
||||
rm -f "$cookie_jar" "$auth_body_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
curl -sf -b "$cookie_jar" -c "$cookie_jar" -X POST \
|
||||
-o /dev/null \
|
||||
"${forge_url}/user/login" \
|
||||
--data-urlencode "_csrf=${csrf}" \
|
||||
--data-urlencode "user_name=${admin_user}" \
|
||||
--data-urlencode "password=${admin_pass}" \
|
||||
2>/dev/null || true
|
||||
|
||||
# Step 2: Start Woodpecker OAuth2 flow (captures authorize URL with state param)
|
||||
local wp_redir
|
||||
wp_redir=$(curl -sf -o /dev/null -w '%{redirect_url}' \
|
||||
"${wp_server}/authorize" 2>/dev/null) || wp_redir=""
|
||||
|
||||
if [ -z "$wp_redir" ]; then
|
||||
echo "Warning: Woodpecker did not provide OAuth redirect — skipping token generation" >&2
|
||||
rm -f "$cookie_jar" "$auth_body_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Rewrite internal Docker network URLs to host-accessible URLs.
|
||||
# Handle both plain and URL-encoded forms of the internal hostnames.
|
||||
local forge_url_enc wp_server_enc
|
||||
forge_url_enc=$(printf '%s' "$forge_url" | sed 's|:|%3A|g; s|/|%2F|g')
|
||||
wp_server_enc=$(printf '%s' "$wp_server" | sed 's|:|%3A|g; s|/|%2F|g')
|
||||
wp_redir=$(printf '%s' "$wp_redir" \
|
||||
| sed "s|http://forgejo:3000|${forge_url}|g" \
|
||||
| sed "s|http%3A%2F%2Fforgejo%3A3000|${forge_url_enc}|g" \
|
||||
| sed "s|http://woodpecker:8000|${wp_server}|g" \
|
||||
| sed "s|http%3A%2F%2Fwoodpecker%3A8000|${wp_server_enc}|g")
|
||||
|
||||
# Step 3: Hit Forgejo OAuth authorize endpoint with session
|
||||
# First time: shows consent page. Already approved: redirects with code.
|
||||
local auth_headers redirect_loc auth_code
|
||||
auth_headers=$(curl -sf -b "$cookie_jar" -c "$cookie_jar" \
|
||||
-D - -o "$auth_body_file" \
|
||||
"$wp_redir" 2>/dev/null) || auth_headers=""
|
||||
|
||||
redirect_loc=$(printf '%s' "$auth_headers" \
|
||||
| grep -i '^location:' | head -1 | tr -d '\r' | awk '{print $2}')
|
||||
|
||||
if printf '%s' "${redirect_loc:-}" | grep -q 'code='; then
|
||||
# Auto-approved: extract code from redirect
|
||||
auth_code=$(printf '%s' "$redirect_loc" | sed 's/.*code=\([^&]*\).*/\1/')
|
||||
else
|
||||
# Consent page: extract CSRF and all form fields, POST grant approval
|
||||
local consent_csrf form_client_id form_state form_redirect_uri
|
||||
consent_csrf=$(grep -o 'name="_csrf"[^>]*' "$auth_body_file" 2>/dev/null \
|
||||
| head -1 | grep -oE '(content|value)="[^"]*"' | head -1 \
|
||||
| cut -d'"' -f2) || consent_csrf=""
|
||||
form_client_id=$(grep 'name="client_id"' "$auth_body_file" 2>/dev/null \
|
||||
| grep -oE 'value="[^"]*"' | cut -d'"' -f2) || form_client_id=""
|
||||
form_state=$(grep 'name="state"' "$auth_body_file" 2>/dev/null \
|
||||
| grep -oE 'value="[^"]*"' | cut -d'"' -f2) || form_state=""
|
||||
form_redirect_uri=$(grep 'name="redirect_uri"' "$auth_body_file" 2>/dev/null \
|
||||
| grep -oE 'value="[^"]*"' | cut -d'"' -f2) || form_redirect_uri=""
|
||||
|
||||
if [ -n "$consent_csrf" ]; then
|
||||
local grant_headers
|
||||
grant_headers=$(curl -sf -b "$cookie_jar" -c "$cookie_jar" \
|
||||
-D - -o /dev/null -X POST \
|
||||
"${forge_url}/login/oauth/grant" \
|
||||
--data-urlencode "_csrf=${consent_csrf}" \
|
||||
--data-urlencode "client_id=${form_client_id}" \
|
||||
--data-urlencode "state=${form_state}" \
|
||||
--data-urlencode "scope=" \
|
||||
--data-urlencode "nonce=" \
|
||||
--data-urlencode "redirect_uri=${form_redirect_uri}" \
|
||||
--data-urlencode "granted=true" \
|
||||
2>/dev/null) || grant_headers=""
|
||||
|
||||
redirect_loc=$(printf '%s' "$grant_headers" \
|
||||
| grep -i '^location:' | head -1 | tr -d '\r' | awk '{print $2}')
|
||||
|
||||
if printf '%s' "${redirect_loc:-}" | grep -q 'code='; then
|
||||
auth_code=$(printf '%s' "$redirect_loc" | sed 's/.*code=\([^&]*\).*/\1/')
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
rm -f "$auth_body_file"
|
||||
|
||||
if [ -z "${auth_code:-}" ]; then
|
||||
echo "Warning: could not obtain OAuth2 authorization code — skipping token generation" >&2
|
||||
rm -f "$cookie_jar"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Step 4: Complete Woodpecker OAuth callback (exchanges code for session)
|
||||
local state
|
||||
state=$(printf '%s' "$wp_redir" | sed -n 's/.*[&?]state=\([^&]*\).*/\1/p')
|
||||
|
||||
local wp_headers wp_token
|
||||
wp_headers=$(curl -sf -c "$cookie_jar" \
|
||||
-D - -o /dev/null \
|
||||
"${wp_server}/authorize?code=${auth_code}&state=${state:-}" \
|
||||
2>/dev/null) || wp_headers=""
|
||||
|
||||
# Extract token from redirect URL (Woodpecker returns ?access_token=...)
|
||||
redirect_loc=$(printf '%s' "$wp_headers" \
|
||||
| grep -i '^location:' | head -1 | tr -d '\r' | awk '{print $2}')
|
||||
|
||||
wp_token=""
|
||||
if printf '%s' "${redirect_loc:-}" | grep -q 'access_token='; then
|
||||
wp_token=$(printf '%s' "$redirect_loc" | sed 's/.*access_token=\([^&]*\).*/\1/')
|
||||
fi
|
||||
|
||||
# Fallback: check for user_sess cookie
|
||||
if [ -z "$wp_token" ]; then
|
||||
wp_token=$(awk '/user_sess/{print $NF}' "$cookie_jar" 2>/dev/null) || wp_token=""
|
||||
fi
|
||||
|
||||
rm -f "$cookie_jar"
|
||||
|
||||
if [ -z "$wp_token" ]; then
|
||||
echo "Warning: could not obtain Woodpecker token — skipping token generation" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Step 5: Create persistent personal access token via Woodpecker API
|
||||
# WP v3 requires CSRF header for POST operations with session tokens.
|
||||
local wp_csrf
|
||||
wp_csrf=$(curl -sf -b "user_sess=${wp_token}" \
|
||||
"${wp_server}/web-config.js" 2>/dev/null \
|
||||
| sed -n 's/.*WOODPECKER_CSRF = "\([^"]*\)".*/\1/p') || wp_csrf=""
|
||||
|
||||
local pat_resp final_token
|
||||
pat_resp=$(curl -sf -X POST \
|
||||
-b "user_sess=${wp_token}" \
|
||||
${wp_csrf:+-H "X-CSRF-Token: ${wp_csrf}"} \
|
||||
"${wp_server}/api/user/token" \
|
||||
2>/dev/null) || pat_resp=""
|
||||
|
||||
final_token=""
|
||||
if [ -n "$pat_resp" ]; then
|
||||
final_token=$(printf '%s' "$pat_resp" \
|
||||
| jq -r 'if .token then .token elif .access_token then .access_token else empty end' \
|
||||
2>/dev/null) || final_token=""
|
||||
fi
|
||||
|
||||
# Use persistent token if available, otherwise use session token
|
||||
final_token="${final_token:-$wp_token}"
|
||||
|
||||
# Save to .env
|
||||
if grep -q '^WOODPECKER_TOKEN=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^WOODPECKER_TOKEN=.*|WOODPECKER_TOKEN=${final_token}|" "$env_file"
|
||||
else
|
||||
printf 'WOODPECKER_TOKEN=%s\n' "$final_token" >> "$env_file"
|
||||
fi
|
||||
export WOODPECKER_TOKEN="$final_token"
|
||||
echo "Config: WOODPECKER_TOKEN generated and saved to .env"
|
||||
}
|
||||
|
||||
# Activate a repo in Woodpecker CI.
|
||||
# Usage: activate_woodpecker_repo <forge_repo>
|
||||
_activate_woodpecker_repo_impl() {
|
||||
local forge_repo="$1"
|
||||
local wp_server="${WOODPECKER_SERVER:-http://localhost:8000}"
|
||||
|
||||
# Wait for Woodpecker to become ready after stack start
|
||||
local retries=0
|
||||
while [ $retries -lt 10 ]; do
|
||||
if curl -sf --max-time 3 "${wp_server}/api/version" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
retries=$((retries + 1))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if ! curl -sf --max-time 5 "${wp_server}/api/version" >/dev/null 2>&1; then
|
||||
echo "Woodpecker: not reachable at ${wp_server} after stack start, skipping repo activation" >&2
|
||||
return
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "── Woodpecker repo activation ─────────────────────────"
|
||||
|
||||
local wp_token="${WOODPECKER_TOKEN:-}"
|
||||
if [ -z "$wp_token" ]; then
|
||||
echo "Warning: WOODPECKER_TOKEN not set — cannot activate repo" >&2
|
||||
echo " Activate manually: woodpecker-cli repo add ${forge_repo}" >&2
|
||||
return
|
||||
fi
|
||||
|
||||
local wp_repo_id
|
||||
wp_repo_id=$(curl -sf \
|
||||
-H "Authorization: Bearer ${wp_token}" \
|
||||
"${wp_server}/api/repos/lookup/${forge_repo}" 2>/dev/null \
|
||||
| jq -r '.id // empty' 2>/dev/null) || true
|
||||
|
||||
if [ -n "$wp_repo_id" ] && [ "$wp_repo_id" != "0" ]; then
|
||||
echo "Repo: ${forge_repo} already active in Woodpecker (id=${wp_repo_id})"
|
||||
else
|
||||
# Get Forgejo repo numeric ID for WP activation
|
||||
local forge_repo_id
|
||||
forge_repo_id=$(curl -sf \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_URL:-http://localhost:3000}/api/v1/repos/${forge_repo}" 2>/dev/null \
|
||||
| jq -r '.id // empty' 2>/dev/null) || forge_repo_id=""
|
||||
|
||||
local activate_resp
|
||||
activate_resp=$(curl -sf -X POST \
|
||||
-H "Authorization: Bearer ${wp_token}" \
|
||||
"${wp_server}/api/repos?forge_remote_id=${forge_repo_id:-0}" \
|
||||
2>/dev/null) || activate_resp=""
|
||||
|
||||
wp_repo_id=$(printf '%s' "$activate_resp" | jq -r '.id // empty' 2>/dev/null) || true
|
||||
|
||||
if [ -n "$wp_repo_id" ] && [ "$wp_repo_id" != "0" ]; then
|
||||
echo "Repo: ${forge_repo} activated in Woodpecker (id=${wp_repo_id})"
|
||||
|
||||
# Set pipeline timeout to 5 minutes (default is 60)
|
||||
if curl -sf -X PATCH \
|
||||
-H "Authorization: Bearer ${wp_token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${wp_server}/api/repos/${wp_repo_id}" \
|
||||
-d '{"timeout": 5}' >/dev/null 2>&1; then
|
||||
echo "Config: pipeline timeout set to 5 minutes"
|
||||
fi
|
||||
else
|
||||
echo "Warning: could not activate repo in Woodpecker" >&2
|
||||
echo " Activate manually: woodpecker-cli repo add ${forge_repo}" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# Store repo ID for later TOML generation
|
||||
if [ -n "$wp_repo_id" ] && [ "$wp_repo_id" != "0" ]; then
|
||||
_WP_REPO_ID="$wp_repo_id"
|
||||
fi
|
||||
}
|
||||
174
lib/env.sh
174
lib/env.sh
|
|
@ -12,63 +12,25 @@ FACTORY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|||
# maps land on the persistent volume instead of /tmp (which is ephemeral).
|
||||
if [ "${DISINTO_CONTAINER:-}" = "1" ]; then
|
||||
DISINTO_DATA_DIR="${HOME}/data"
|
||||
DISINTO_LOG_DIR="${DISINTO_DATA_DIR}/logs"
|
||||
mkdir -p "${DISINTO_DATA_DIR}" "${DISINTO_LOG_DIR}"/{dev,action,review,supervisor,vault,site,metrics,gardener,planner,predictor,architect,dispatcher}
|
||||
else
|
||||
DISINTO_LOG_DIR="${FACTORY_ROOT}"
|
||||
mkdir -p "${DISINTO_DATA_DIR}"
|
||||
fi
|
||||
export DISINTO_LOG_DIR
|
||||
|
||||
# Load secrets: prefer .env.enc (SOPS-encrypted), fall back to plaintext .env.
|
||||
# Always source .env — cron jobs inside the container do NOT inherit compose
|
||||
# env vars (FORGE_TOKEN, etc.). Only FORGE_URL is preserved across .env
|
||||
# sourcing because compose injects http://forgejo:3000 while .env has
|
||||
# http://localhost:3000. FORGE_TOKEN is NOT preserved so that refreshed
|
||||
# tokens in .env take effect immediately in running containers.
|
||||
if [ -f "$FACTORY_ROOT/.env.enc" ] && command -v sops &>/dev/null; then
|
||||
set -a
|
||||
_saved_forge_url="${FORGE_URL:-}"
|
||||
# Use temp file + validate dotenv format before sourcing (avoids eval injection)
|
||||
# SOPS -d automatically verifies MAC/GCM authentication tag during decryption
|
||||
_tmpenv=$(mktemp) || { echo "Error: failed to create temp file for .env.enc" >&2; exit 1; }
|
||||
if ! sops -d --output-type dotenv "$FACTORY_ROOT/.env.enc" > "$_tmpenv" 2>/dev/null; then
|
||||
echo "Error: failed to decrypt .env.enc — decryption failed, possible corruption" >&2
|
||||
rm -f "$_tmpenv"
|
||||
exit 1
|
||||
fi
|
||||
# Validate: non-empty, non-comment lines must match KEY=value pattern
|
||||
# Filter out blank lines and comments before validation
|
||||
_validated=$(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$_tmpenv" 2>/dev/null || true)
|
||||
if [ -n "$_validated" ]; then
|
||||
# Write validated content to a second temp file and source it
|
||||
_validated_env=$(mktemp)
|
||||
printf '%s\n' "$_validated" > "$_validated_env"
|
||||
# Inside the container, compose already injects env vars via env_file + environment
|
||||
# overrides (e.g. FORGE_URL=http://forgejo:3000). Re-sourcing .env would clobber
|
||||
# those compose-level values, so we skip it when DISINTO_CONTAINER=1.
|
||||
if [ "${DISINTO_CONTAINER:-}" != "1" ]; then
|
||||
if [ -f "$FACTORY_ROOT/.env.enc" ] && command -v sops &>/dev/null; then
|
||||
set -a
|
||||
eval "$(sops -d --output-type dotenv "$FACTORY_ROOT/.env.enc" 2>/dev/null)" \
|
||||
|| echo "Warning: failed to decrypt .env.enc — secrets not loaded" >&2
|
||||
set +a
|
||||
elif [ -f "$FACTORY_ROOT/.env" ]; then
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
source "$_validated_env"
|
||||
rm -f "$_validated_env"
|
||||
else
|
||||
echo "Error: .env.enc decryption output failed format validation" >&2
|
||||
rm -f "$_tmpenv"
|
||||
exit 1
|
||||
source "$FACTORY_ROOT/.env"
|
||||
set +a
|
||||
fi
|
||||
rm -f "$_tmpenv"
|
||||
set +a
|
||||
[ -n "$_saved_forge_url" ] && export FORGE_URL="$_saved_forge_url"
|
||||
elif [ -f "$FACTORY_ROOT/.env" ]; then
|
||||
# Preserve compose-injected FORGE_URL (localhost in .env != forgejo in Docker)
|
||||
_saved_forge_url="${FORGE_URL:-}"
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
source "$FACTORY_ROOT/.env"
|
||||
set +a
|
||||
[ -n "$_saved_forge_url" ] && export FORGE_URL="$_saved_forge_url"
|
||||
fi
|
||||
|
||||
# Allow per-container token override (#375): .env sets the default FORGE_TOKEN
|
||||
# (dev-bot), then FORGE_TOKEN_OVERRIDE replaces it for containers that need a
|
||||
# different Forgejo identity (e.g. dev-qwen).
|
||||
if [ -n "${FORGE_TOKEN_OVERRIDE:-}" ]; then
|
||||
export FORGE_TOKEN="$FORGE_TOKEN_OVERRIDE"
|
||||
fi
|
||||
|
||||
# PATH: foundry, node, system
|
||||
|
|
@ -80,11 +42,16 @@ if [ -n "${PROJECT_TOML:-}" ] && [ -f "$PROJECT_TOML" ]; then
|
|||
source "${FACTORY_ROOT}/lib/load-project.sh" "$PROJECT_TOML"
|
||||
fi
|
||||
|
||||
# Forge token
|
||||
export FORGE_TOKEN="${FORGE_TOKEN:-}"
|
||||
# Forge token: new FORGE_TOKEN > legacy CODEBERG_TOKEN
|
||||
if [ -z "${FORGE_TOKEN:-}" ]; then
|
||||
FORGE_TOKEN="${CODEBERG_TOKEN:-}"
|
||||
fi
|
||||
export FORGE_TOKEN
|
||||
export CODEBERG_TOKEN="${FORGE_TOKEN}" # backwards compat
|
||||
|
||||
# Review bot token
|
||||
# Review bot token: FORGE_REVIEW_TOKEN > legacy REVIEW_BOT_TOKEN
|
||||
export FORGE_REVIEW_TOKEN="${FORGE_REVIEW_TOKEN:-${REVIEW_BOT_TOKEN:-}}"
|
||||
export REVIEW_BOT_TOKEN="${FORGE_REVIEW_TOKEN}" # backwards compat
|
||||
|
||||
# Per-agent tokens (#747): each agent gets its own Forgejo identity.
|
||||
# Falls back to FORGE_TOKEN for backwards compat with single-token setups.
|
||||
|
|
@ -93,16 +60,20 @@ export FORGE_GARDENER_TOKEN="${FORGE_GARDENER_TOKEN:-${FORGE_TOKEN}}"
|
|||
export FORGE_VAULT_TOKEN="${FORGE_VAULT_TOKEN:-${FORGE_TOKEN}}"
|
||||
export FORGE_SUPERVISOR_TOKEN="${FORGE_SUPERVISOR_TOKEN:-${FORGE_TOKEN}}"
|
||||
export FORGE_PREDICTOR_TOKEN="${FORGE_PREDICTOR_TOKEN:-${FORGE_TOKEN}}"
|
||||
export FORGE_ARCHITECT_TOKEN="${FORGE_ARCHITECT_TOKEN:-${FORGE_TOKEN}}"
|
||||
export FORGE_ACTION_TOKEN="${FORGE_ACTION_TOKEN:-${FORGE_TOKEN}}"
|
||||
|
||||
# Bot usernames filter
|
||||
export FORGE_BOT_USERNAMES="${FORGE_BOT_USERNAMES:-dev-bot,review-bot,planner-bot,gardener-bot,vault-bot,supervisor-bot,predictor-bot,architect-bot}"
|
||||
# Bot usernames filter: FORGE_BOT_USERNAMES > legacy CODEBERG_BOT_USERNAMES
|
||||
export FORGE_BOT_USERNAMES="${FORGE_BOT_USERNAMES:-${CODEBERG_BOT_USERNAMES:-dev-bot,review-bot,planner-bot,gardener-bot,vault-bot,supervisor-bot,predictor-bot,action-bot}}"
|
||||
export CODEBERG_BOT_USERNAMES="${FORGE_BOT_USERNAMES}" # backwards compat
|
||||
|
||||
# Project config
|
||||
export FORGE_REPO="${FORGE_REPO:-}"
|
||||
# Project config (FORGE_* preferred, CODEBERG_* fallback)
|
||||
export FORGE_REPO="${FORGE_REPO:-${CODEBERG_REPO:-}}"
|
||||
export CODEBERG_REPO="${FORGE_REPO}" # backwards compat
|
||||
export FORGE_URL="${FORGE_URL:-http://localhost:3000}"
|
||||
export FORGE_API="${FORGE_API:-${FORGE_URL}/api/v1/repos/${FORGE_REPO}}"
|
||||
export FORGE_WEB="${FORGE_WEB:-${FORGE_URL}/${FORGE_REPO}}"
|
||||
export CODEBERG_API="${FORGE_API}" # backwards compat
|
||||
export CODEBERG_WEB="${FORGE_WEB}" # backwards compat
|
||||
# tea CLI login name: derived from FORGE_URL (codeberg vs local forgejo)
|
||||
if [ -z "${TEA_LOGIN:-}" ]; then
|
||||
case "${FORGE_URL}" in
|
||||
|
|
@ -128,7 +99,7 @@ export CLAUDE_TIMEOUT="${CLAUDE_TIMEOUT:-7200}"
|
|||
|
||||
# Vault-only token guard (#745): external-action tokens (GITHUB_TOKEN, CLAWHUB_TOKEN)
|
||||
# must NEVER be available to agents. They live in .env.vault.enc and are injected
|
||||
# only into the ephemeral runner container at fire time. Unset them here so
|
||||
# only into the ephemeral vault-runner container at fire time. Unset them here so
|
||||
# even an accidental .env inclusion cannot leak them into agent sessions.
|
||||
unset GITHUB_TOKEN 2>/dev/null || true
|
||||
unset CLAWHUB_TOKEN 2>/dev/null || true
|
||||
|
|
@ -138,75 +109,21 @@ unset CLAWHUB_TOKEN 2>/dev/null || true
|
|||
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
|
||||
|
||||
# Shared log helper
|
||||
# Usage: log "message"
|
||||
# Output: [2026-04-03T14:00:00Z] agent: message
|
||||
# Where agent is set via LOG_AGENT variable (defaults to caller's context)
|
||||
log() {
|
||||
local agent="${LOG_AGENT:-agent}"
|
||||
printf '[%s] %s: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$agent" "$*"
|
||||
printf '[%s] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# URL VALIDATION HELPER
|
||||
# =============================================================================
|
||||
# Validates that a URL variable matches expected patterns to prevent
|
||||
# URL injection or redirection attacks (OWASP URL Redirection prevention).
|
||||
# Returns 0 if valid, 1 if invalid.
|
||||
# =============================================================================
|
||||
validate_url() {
|
||||
local url="$1"
|
||||
local allowed_hosts="${2:-}"
|
||||
|
||||
# Must start with http:// or https://
|
||||
if [[ ! "$url" =~ ^https?:// ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Extract host and reject if it contains @ (credential injection)
|
||||
if [[ "$url" =~ ^https?://[^@]+@ ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# If allowed_hosts is specified, validate against it
|
||||
if [ -n "$allowed_hosts" ]; then
|
||||
local host
|
||||
host=$(echo "$url" | sed -E 's|^https?://([^/:]+).*|\1|')
|
||||
local valid=false
|
||||
for allowed in $allowed_hosts; do
|
||||
if [ "$host" = "$allowed" ]; then
|
||||
valid=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$valid" = false ]; then
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# FORGE API HELPER
|
||||
# =============================================================================
|
||||
# Usage: forge_api GET /issues?state=open
|
||||
# Validates FORGE_API before use to prevent URL injection attacks.
|
||||
# =============================================================================
|
||||
# Forge API helper — usage: forge_api GET /issues?state=open
|
||||
forge_api() {
|
||||
local method="$1" path="$2"
|
||||
shift 2
|
||||
|
||||
# Validate FORGE_API to prevent URL injection
|
||||
if ! validate_url "$FORGE_API"; then
|
||||
echo "ERROR: FORGE_API validation failed - possible URL injection attempt" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
curl -sf -X "$method" \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}${path}" "$@"
|
||||
}
|
||||
# Backwards-compat alias
|
||||
codeberg_api() { forge_api "$@"; }
|
||||
|
||||
# Paginate a Forge API GET endpoint and return all items as a merged JSON array.
|
||||
# Usage: forge_api_all /path (no existing query params)
|
||||
|
|
@ -223,8 +140,7 @@ forge_api_all() {
|
|||
page=1
|
||||
while true; do
|
||||
page_items=$(forge_api GET "${path_prefix}${sep}limit=50&page=${page}")
|
||||
count=$(printf '%s' "$page_items" | jq 'length' 2>/dev/null) || count=0
|
||||
[ -z "$count" ] && count=0
|
||||
count=$(printf '%s' "$page_items" | jq 'length')
|
||||
[ "$count" -eq 0 ] && break
|
||||
all_items=$(printf '%s\n%s' "$all_items" "$page_items" | jq -s 'add')
|
||||
[ "$count" -lt 50 ] && break
|
||||
|
|
@ -232,23 +148,13 @@ forge_api_all() {
|
|||
done
|
||||
printf '%s' "$all_items"
|
||||
}
|
||||
# Backwards-compat alias
|
||||
codeberg_api_all() { forge_api_all "$@"; }
|
||||
|
||||
# =============================================================================
|
||||
# WOODPECKER API HELPER
|
||||
# =============================================================================
|
||||
# Usage: woodpecker_api /repos/{id}/pipelines
|
||||
# Validates WOODPECKER_SERVER before use to prevent URL injection attacks.
|
||||
# =============================================================================
|
||||
# Woodpecker API helper
|
||||
woodpecker_api() {
|
||||
local path="$1"
|
||||
shift
|
||||
|
||||
# Validate WOODPECKER_SERVER to prevent URL injection
|
||||
if ! validate_url "$WOODPECKER_SERVER"; then
|
||||
echo "ERROR: WOODPECKER_SERVER validation failed - possible URL injection attempt" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
curl -sfL \
|
||||
-H "Authorization: Bearer ${WOODPECKER_TOKEN}" \
|
||||
"${WOODPECKER_SERVER}/api${path}" "$@"
|
||||
|
|
|
|||
59
lib/file-action-issue.sh
Normal file
59
lib/file-action-issue.sh
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env bash
|
||||
# file-action-issue.sh — File an action issue for a formula run
|
||||
#
|
||||
# Usage: source this file, then call file_action_issue.
|
||||
# Requires: forge_api() from lib/env.sh, jq, lib/secret-scan.sh
|
||||
#
|
||||
# file_action_issue <formula_name> <title> <body>
|
||||
# Sets FILED_ISSUE_NUM on success.
|
||||
# Returns: 0=created, 1=duplicate exists, 2=label not found, 3=API error, 4=secrets detected
|
||||
|
||||
# Load secret scanner
|
||||
# shellcheck source=secret-scan.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/secret-scan.sh"
|
||||
|
||||
file_action_issue() {
|
||||
local formula_name="$1" title="$2" body="$3"
|
||||
FILED_ISSUE_NUM=""
|
||||
|
||||
# Secret scan: reject issue bodies containing embedded secrets
|
||||
if ! scan_for_secrets "$body"; then
|
||||
echo "file-action-issue: BLOCKED — issue body for '${formula_name}' contains potential secrets. Use env var references instead." >&2
|
||||
return 4
|
||||
fi
|
||||
|
||||
# Dedup: skip if an open action issue for this formula already exists
|
||||
local open_actions
|
||||
open_actions=$(forge_api_all "/issues?state=open&type=issues&labels=action" 2>/dev/null || true)
|
||||
if [ -n "$open_actions" ] && [ "$open_actions" != "null" ]; then
|
||||
local existing
|
||||
existing=$(printf '%s' "$open_actions" | \
|
||||
jq --arg f "$formula_name" '[.[] | select(.title | test($f))] | length' 2>/dev/null || echo 0)
|
||||
if [ "${existing:-0}" -gt 0 ]; then
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fetch 'action' label ID
|
||||
local action_label_id
|
||||
action_label_id=$(forge_api GET "/labels" 2>/dev/null | \
|
||||
jq -r '.[] | select(.name == "action") | .id' 2>/dev/null || true)
|
||||
if [ -z "$action_label_id" ]; then
|
||||
return 2
|
||||
fi
|
||||
|
||||
# Create the issue
|
||||
local payload result
|
||||
payload=$(jq -nc \
|
||||
--arg title "$title" \
|
||||
--arg body "$body" \
|
||||
--argjson labels "[$action_label_id]" \
|
||||
'{title: $title, body: $body, labels: $labels}')
|
||||
|
||||
result=$(forge_api POST "/issues" -d "$payload" 2>/dev/null || true)
|
||||
FILED_ISSUE_NUM=$(printf '%s' "$result" | jq -r '.number // empty' 2>/dev/null || true)
|
||||
|
||||
if [ -z "$FILED_ISSUE_NUM" ]; then
|
||||
return 3
|
||||
fi
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# forge-push.sh — push_to_forge() function
|
||||
#
|
||||
# Handles pushing a local clone to the Forgejo remote and verifying the push.
|
||||
#
|
||||
# Globals expected:
|
||||
# FORGE_URL - Forge instance URL (e.g. http://localhost:3000)
|
||||
# FORGE_TOKEN - API token for Forge operations (used for API verification)
|
||||
# FORGE_PASS - Bot password for git HTTP push (#361: tokens rejected by Forgejo 11.x)
|
||||
# FACTORY_ROOT - Root of the disinto factory
|
||||
# PRIMARY_BRANCH - Primary branch name (e.g. main)
|
||||
#
|
||||
# Usage:
|
||||
# source "${FACTORY_ROOT}/lib/forge-push.sh"
|
||||
# push_to_forge <repo_root> <forge_url> <repo_slug>
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# Assert required globals are set before using this module.
|
||||
_assert_forge_push_globals() {
|
||||
local missing=()
|
||||
[ -z "${FORGE_URL:-}" ] && missing+=("FORGE_URL")
|
||||
[ -z "${FORGE_PASS:-}" ] && missing+=("FORGE_PASS")
|
||||
[ -z "${FORGE_TOKEN:-}" ] && missing+=("FORGE_TOKEN")
|
||||
[ -z "${FACTORY_ROOT:-}" ] && missing+=("FACTORY_ROOT")
|
||||
[ -z "${PRIMARY_BRANCH:-}" ] && missing+=("PRIMARY_BRANCH")
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
echo "Error: forge-push.sh requires these globals to be set: ${missing[*]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Push local clone to the Forgejo remote.
|
||||
push_to_forge() {
|
||||
local repo_root="$1" forge_url="$2" repo_slug="$3"
|
||||
|
||||
# Build authenticated remote URL: http://dev-bot:<password>@host:port/org/repo.git
|
||||
# Forgejo 11.x rejects API tokens for git HTTP push (#361); password auth works.
|
||||
if [ -z "${FORGE_PASS:-}" ]; then
|
||||
echo "Error: FORGE_PASS not set — cannot push to Forgejo (see #361)" >&2
|
||||
return 1
|
||||
fi
|
||||
local auth_url
|
||||
auth_url=$(printf '%s' "$forge_url" | sed "s|://|://dev-bot:${FORGE_PASS}@|")
|
||||
local remote_url="${auth_url}/${repo_slug}.git"
|
||||
# Display URL without token
|
||||
local display_url="${forge_url}/${repo_slug}.git"
|
||||
|
||||
# Always set the remote URL to ensure credentials are current
|
||||
if git -C "$repo_root" remote get-url forgejo >/dev/null 2>&1; then
|
||||
git -C "$repo_root" remote set-url forgejo "$remote_url"
|
||||
else
|
||||
git -C "$repo_root" remote add forgejo "$remote_url"
|
||||
fi
|
||||
echo "Remote: forgejo -> ${display_url}"
|
||||
|
||||
# Skip push if local repo has no commits (e.g. cloned from empty Forgejo repo)
|
||||
if ! git -C "$repo_root" rev-parse HEAD >/dev/null 2>&1; then
|
||||
echo "Push: skipped (local repo has no commits)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Push all branches and tags
|
||||
echo "Pushing: branches to forgejo"
|
||||
if ! git -C "$repo_root" push forgejo --all 2>&1; then
|
||||
echo "Error: failed to push branches to Forgejo" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Pushing: tags to forgejo"
|
||||
if ! git -C "$repo_root" push forgejo --tags 2>&1; then
|
||||
echo "Error: failed to push tags to Forgejo" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Verify the repo is no longer empty (Forgejo may need a moment to index pushed refs)
|
||||
local is_empty="true"
|
||||
local verify_attempt
|
||||
for verify_attempt in $(seq 1 5); do
|
||||
local repo_info
|
||||
repo_info=$(curl -sf --max-time 10 \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${forge_url}/api/v1/repos/${repo_slug}" 2>/dev/null) || repo_info=""
|
||||
if [ -z "$repo_info" ]; then
|
||||
is_empty="skipped"
|
||||
break # API unreachable, skip verification
|
||||
fi
|
||||
is_empty=$(printf '%s' "$repo_info" | jq -r '.empty // "unknown"')
|
||||
if [ "$is_empty" != "true" ]; then
|
||||
echo "Verify: repo is not empty (push confirmed)"
|
||||
break
|
||||
fi
|
||||
if [ "$verify_attempt" -lt 5 ]; then
|
||||
sleep 2
|
||||
fi
|
||||
done
|
||||
if [ "$is_empty" = "true" ]; then
|
||||
echo "Warning: Forgejo repo still reports empty after push" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
|
@ -1,518 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# forge-setup.sh — setup_forge() and helpers for Forgejo provisioning
|
||||
#
|
||||
# Handles admin user creation, bot user creation, token generation,
|
||||
# password resets, repo creation, and collaborator setup.
|
||||
#
|
||||
# Globals expected (asserted by _load_init_context):
|
||||
# FORGE_URL - Forge instance URL (e.g. http://localhost:3000)
|
||||
# FACTORY_ROOT - Root of the disinto factory
|
||||
# PRIMARY_BRANCH - Primary branch name (e.g. main)
|
||||
#
|
||||
# Usage:
|
||||
# source "${FACTORY_ROOT}/lib/forge-setup.sh"
|
||||
# setup_forge <forge_url> <repo_slug>
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# Assert required globals are set before using this module.
|
||||
_load_init_context() {
|
||||
local missing=()
|
||||
[ -z "${FORGE_URL:-}" ] && missing+=("FORGE_URL")
|
||||
[ -z "${FACTORY_ROOT:-}" ] && missing+=("FACTORY_ROOT")
|
||||
[ -z "${PRIMARY_BRANCH:-}" ] && missing+=("PRIMARY_BRANCH")
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
echo "Error: forge-setup.sh requires these globals to be set: ${missing[*]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Execute a command in the Forgejo container (for admin operations)
|
||||
_forgejo_exec() {
|
||||
local use_bare="${DISINTO_BARE:-false}"
|
||||
if [ "$use_bare" = true ]; then
|
||||
docker exec -u git disinto-forgejo "$@"
|
||||
else
|
||||
docker compose -f "${FACTORY_ROOT}/docker-compose.yml" exec -T -u git forgejo "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Provision or connect to a local Forgejo instance.
|
||||
# Creates admin + bot users, generates API tokens, stores in .env.
|
||||
# When $DISINTO_BARE is set, uses standalone docker run; otherwise uses compose.
|
||||
setup_forge() {
|
||||
local forge_url="$1"
|
||||
local repo_slug="$2"
|
||||
local use_bare="${DISINTO_BARE:-false}"
|
||||
|
||||
echo ""
|
||||
echo "── Forge setup ────────────────────────────────────────"
|
||||
|
||||
# Check if Forgejo is already running
|
||||
if curl -sf --max-time 5 "${forge_url}/api/v1/version" >/dev/null 2>&1; then
|
||||
echo "Forgejo: ${forge_url} (already running)"
|
||||
else
|
||||
echo "Forgejo not reachable at ${forge_url}"
|
||||
echo "Starting Forgejo via Docker..."
|
||||
|
||||
if ! command -v docker &>/dev/null; then
|
||||
echo "Error: docker not found — needed to provision Forgejo" >&2
|
||||
echo " Install Docker or start Forgejo manually at ${forge_url}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract port from forge_url
|
||||
local forge_port
|
||||
forge_port=$(printf '%s' "$forge_url" | sed -E 's|.*:([0-9]+)/?$|\1|')
|
||||
forge_port="${forge_port:-3000}"
|
||||
|
||||
if [ "$use_bare" = true ]; then
|
||||
# Bare-metal mode: standalone docker run
|
||||
mkdir -p "${FORGEJO_DATA_DIR}"
|
||||
|
||||
if docker ps -a --format '{{.Names}}' | grep -q '^disinto-forgejo$'; then
|
||||
docker start disinto-forgejo >/dev/null 2>&1 || true
|
||||
else
|
||||
docker run -d \
|
||||
--name disinto-forgejo \
|
||||
--restart unless-stopped \
|
||||
-p "${forge_port}:3000" \
|
||||
-p 2222:22 \
|
||||
-v "${FORGEJO_DATA_DIR}:/data" \
|
||||
-e "FORGEJO__database__DB_TYPE=sqlite3" \
|
||||
-e "FORGEJO__server__ROOT_URL=${forge_url}/" \
|
||||
-e "FORGEJO__server__HTTP_PORT=3000" \
|
||||
-e "FORGEJO__service__DISABLE_REGISTRATION=true" \
|
||||
codeberg.org/forgejo/forgejo:11.0
|
||||
fi
|
||||
else
|
||||
# Compose mode: start Forgejo via docker compose
|
||||
docker compose -f "${FACTORY_ROOT}/docker-compose.yml" up -d forgejo
|
||||
fi
|
||||
|
||||
# Wait for Forgejo to become healthy
|
||||
echo -n "Waiting for Forgejo to start"
|
||||
local retries=0
|
||||
while ! curl -sf --max-time 3 "${forge_url}/api/v1/version" >/dev/null 2>&1; do
|
||||
retries=$((retries + 1))
|
||||
if [ "$retries" -gt 60 ]; then
|
||||
echo ""
|
||||
echo "Error: Forgejo did not become ready within 60s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 1
|
||||
done
|
||||
echo " ready"
|
||||
fi
|
||||
|
||||
# Wait for Forgejo database to accept writes (API may be ready before DB is)
|
||||
echo -n "Waiting for Forgejo database"
|
||||
local db_ready=false
|
||||
for _i in $(seq 1 30); do
|
||||
if _forgejo_exec forgejo admin user list >/dev/null 2>&1; then
|
||||
db_ready=true
|
||||
break
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 1
|
||||
done
|
||||
echo ""
|
||||
if [ "$db_ready" != true ]; then
|
||||
echo "Error: Forgejo database not ready after 30s" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create admin user if it doesn't exist
|
||||
local admin_user="disinto-admin"
|
||||
local admin_pass
|
||||
local env_file="${FACTORY_ROOT}/.env"
|
||||
|
||||
# Re-read persisted admin password if available (#158)
|
||||
if grep -q '^FORGE_ADMIN_PASS=' "$env_file" 2>/dev/null; then
|
||||
admin_pass=$(grep '^FORGE_ADMIN_PASS=' "$env_file" | head -1 | cut -d= -f2-)
|
||||
fi
|
||||
# Generate a fresh password only when none was persisted
|
||||
if [ -z "${admin_pass:-}" ]; then
|
||||
admin_pass="admin-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)"
|
||||
fi
|
||||
|
||||
if ! curl -sf --max-time 5 "${forge_url}/api/v1/users/${admin_user}" >/dev/null 2>&1; then
|
||||
echo "Creating admin user: ${admin_user}"
|
||||
local create_output
|
||||
if ! create_output=$(_forgejo_exec forgejo admin user create \
|
||||
--admin \
|
||||
--username "${admin_user}" \
|
||||
--password "${admin_pass}" \
|
||||
--email "admin@disinto.local" \
|
||||
--must-change-password=false 2>&1); then
|
||||
echo "Error: failed to create admin user '${admin_user}':" >&2
|
||||
echo " ${create_output}" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Forgejo 11.x ignores --must-change-password=false on create;
|
||||
# explicitly clear the flag so basic-auth token creation works.
|
||||
_forgejo_exec forgejo admin user change-password \
|
||||
--username "${admin_user}" \
|
||||
--password "${admin_pass}" \
|
||||
--must-change-password=false
|
||||
|
||||
# Verify admin user was actually created
|
||||
if ! curl -sf --max-time 5 "${forge_url}/api/v1/users/${admin_user}" >/dev/null 2>&1; then
|
||||
echo "Error: admin user '${admin_user}' not found after creation" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Persist admin password to .env for idempotent re-runs (#158)
|
||||
if grep -q '^FORGE_ADMIN_PASS=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^FORGE_ADMIN_PASS=.*|FORGE_ADMIN_PASS=${admin_pass}|" "$env_file"
|
||||
else
|
||||
printf 'FORGE_ADMIN_PASS=%s\n' "$admin_pass" >> "$env_file"
|
||||
fi
|
||||
else
|
||||
echo "Admin user: ${admin_user} (already exists)"
|
||||
# Only reset password if basic auth fails (#158, #267)
|
||||
# Forgejo 11.x may ignore --must-change-password=false, blocking token creation
|
||||
if ! curl -sf --max-time 5 -u "${admin_user}:${admin_pass}" \
|
||||
"${forge_url}/api/v1/user" >/dev/null 2>&1; then
|
||||
_forgejo_exec forgejo admin user change-password \
|
||||
--username "${admin_user}" \
|
||||
--password "${admin_pass}" \
|
||||
--must-change-password=false
|
||||
fi
|
||||
fi
|
||||
# Preserve password for Woodpecker OAuth2 token generation (#779)
|
||||
_FORGE_ADMIN_PASS="$admin_pass"
|
||||
|
||||
# Create human user (disinto-admin) as site admin if it doesn't exist
|
||||
local human_user="disinto-admin"
|
||||
local human_pass
|
||||
human_pass="admin-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)"
|
||||
|
||||
if ! curl -sf --max-time 5 "${forge_url}/api/v1/users/${human_user}" >/dev/null 2>&1; then
|
||||
echo "Creating human user: ${human_user}"
|
||||
local create_output
|
||||
if ! create_output=$(_forgejo_exec forgejo admin user create \
|
||||
--admin \
|
||||
--username "${human_user}" \
|
||||
--password "${human_pass}" \
|
||||
--email "admin@disinto.local" \
|
||||
--must-change-password=false 2>&1); then
|
||||
echo "Error: failed to create human user '${human_user}':" >&2
|
||||
echo " ${create_output}" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Forgejo 11.x ignores --must-change-password=false on create;
|
||||
# explicitly clear the flag so basic-auth token creation works.
|
||||
_forgejo_exec forgejo admin user change-password \
|
||||
--username "${human_user}" \
|
||||
--password "${human_pass}" \
|
||||
--must-change-password=false
|
||||
|
||||
# Verify human user was actually created
|
||||
if ! curl -sf --max-time 5 "${forge_url}/api/v1/users/${human_user}" >/dev/null 2>&1; then
|
||||
echo "Error: human user '${human_user}' not found after creation" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " Human user '${human_user}' created as site admin"
|
||||
else
|
||||
echo "Human user: ${human_user} (already exists)"
|
||||
fi
|
||||
|
||||
# Delete existing admin token if present (token sha1 is only returned at creation time)
|
||||
local existing_token_id
|
||||
existing_token_id=$(curl -sf \
|
||||
-u "${admin_user}:${admin_pass}" \
|
||||
"${forge_url}/api/v1/users/${admin_user}/tokens" 2>/dev/null \
|
||||
| jq -r '.[] | select(.name == "disinto-admin-token") | .id') || existing_token_id=""
|
||||
if [ -n "$existing_token_id" ]; then
|
||||
curl -sf -X DELETE \
|
||||
-u "${admin_user}:${admin_pass}" \
|
||||
"${forge_url}/api/v1/users/${admin_user}/tokens/${existing_token_id}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Create admin token (fresh, so sha1 is returned)
|
||||
local admin_token
|
||||
admin_token=$(curl -sf -X POST \
|
||||
-u "${admin_user}:${admin_pass}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/users/${admin_user}/tokens" \
|
||||
-d '{"name":"disinto-admin-token","scopes":["all"]}' 2>/dev/null \
|
||||
| jq -r '.sha1 // empty') || admin_token=""
|
||||
|
||||
if [ -z "$admin_token" ]; then
|
||||
echo "Error: failed to obtain admin API token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get or create human user token
|
||||
local human_token
|
||||
if curl -sf --max-time 5 "${forge_url}/api/v1/users/${human_user}" >/dev/null 2>&1; then
|
||||
# Delete existing human token if present (token sha1 is only returned at creation time)
|
||||
local existing_human_token_id
|
||||
existing_human_token_id=$(curl -sf \
|
||||
-u "${human_user}:${human_pass}" \
|
||||
"${forge_url}/api/v1/users/${human_user}/tokens" 2>/dev/null \
|
||||
| jq -r '.[] | select(.name == "disinto-human-token") | .id') || existing_human_token_id=""
|
||||
if [ -n "$existing_human_token_id" ]; then
|
||||
curl -sf -X DELETE \
|
||||
-u "${human_user}:${human_pass}" \
|
||||
"${forge_url}/api/v1/users/${human_user}/tokens/${existing_human_token_id}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Create human token (fresh, so sha1 is returned)
|
||||
human_token=$(curl -sf -X POST \
|
||||
-u "${human_user}:${human_pass}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/users/${human_user}/tokens" \
|
||||
-d '{"name":"disinto-human-token","scopes":["all"]}' 2>/dev/null \
|
||||
| jq -r '.sha1 // empty') || human_token=""
|
||||
|
||||
if [ -n "$human_token" ]; then
|
||||
# Store human token in .env
|
||||
if grep -q '^HUMAN_TOKEN=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^HUMAN_TOKEN=.*|HUMAN_TOKEN=${human_token}|" "$env_file"
|
||||
else
|
||||
printf 'HUMAN_TOKEN=%s\n' "$human_token" >> "$env_file"
|
||||
fi
|
||||
export HUMAN_TOKEN="$human_token"
|
||||
echo " Human token saved (HUMAN_TOKEN)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create bot users and tokens
|
||||
# Each agent gets its own Forgejo account for identity and audit trail (#747).
|
||||
# Map: bot-username -> env-var-name for the token
|
||||
local -A bot_token_vars=(
|
||||
[dev-bot]="FORGE_TOKEN"
|
||||
[review-bot]="FORGE_REVIEW_TOKEN"
|
||||
[planner-bot]="FORGE_PLANNER_TOKEN"
|
||||
[gardener-bot]="FORGE_GARDENER_TOKEN"
|
||||
[vault-bot]="FORGE_VAULT_TOKEN"
|
||||
[supervisor-bot]="FORGE_SUPERVISOR_TOKEN"
|
||||
[predictor-bot]="FORGE_PREDICTOR_TOKEN"
|
||||
[architect-bot]="FORGE_ARCHITECT_TOKEN"
|
||||
)
|
||||
# Map: bot-username -> env-var-name for the password
|
||||
# Forgejo 11.x API tokens don't work for git HTTP push (#361).
|
||||
# Store passwords so agents can use password auth for git operations.
|
||||
local -A bot_pass_vars=(
|
||||
[dev-bot]="FORGE_PASS"
|
||||
[review-bot]="FORGE_REVIEW_PASS"
|
||||
[planner-bot]="FORGE_PLANNER_PASS"
|
||||
[gardener-bot]="FORGE_GARDENER_PASS"
|
||||
[vault-bot]="FORGE_VAULT_PASS"
|
||||
[supervisor-bot]="FORGE_SUPERVISOR_PASS"
|
||||
[predictor-bot]="FORGE_PREDICTOR_PASS"
|
||||
[architect-bot]="FORGE_ARCHITECT_PASS"
|
||||
)
|
||||
|
||||
local bot_user bot_pass token token_var pass_var
|
||||
|
||||
for bot_user in dev-bot review-bot planner-bot gardener-bot vault-bot supervisor-bot predictor-bot architect-bot; do
|
||||
bot_pass="bot-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)"
|
||||
token_var="${bot_token_vars[$bot_user]}"
|
||||
|
||||
# Check if bot user exists
|
||||
local user_exists=false
|
||||
if curl -sf --max-time 5 \
|
||||
-H "Authorization: token ${admin_token}" \
|
||||
"${forge_url}/api/v1/users/${bot_user}" >/dev/null 2>&1; then
|
||||
user_exists=true
|
||||
fi
|
||||
|
||||
if [ "$user_exists" = false ]; then
|
||||
echo "Creating bot user: ${bot_user}"
|
||||
local create_output
|
||||
if ! create_output=$(_forgejo_exec forgejo admin user create \
|
||||
--username "${bot_user}" \
|
||||
--password "${bot_pass}" \
|
||||
--email "${bot_user}@disinto.local" \
|
||||
--must-change-password=false 2>&1); then
|
||||
echo "Error: failed to create bot user '${bot_user}':" >&2
|
||||
echo " ${create_output}" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Forgejo 11.x ignores --must-change-password=false on create;
|
||||
# explicitly clear the flag so basic-auth token creation works.
|
||||
_forgejo_exec forgejo admin user change-password \
|
||||
--username "${bot_user}" \
|
||||
--password "${bot_pass}" \
|
||||
--must-change-password=false
|
||||
|
||||
# Verify bot user was actually created
|
||||
if ! curl -sf --max-time 5 \
|
||||
-H "Authorization: token ${admin_token}" \
|
||||
"${forge_url}/api/v1/users/${bot_user}" >/dev/null 2>&1; then
|
||||
echo "Error: bot user '${bot_user}' not found after creation" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " ${bot_user} user created"
|
||||
else
|
||||
echo " ${bot_user} user exists (resetting password for token generation)"
|
||||
# User exists but may not have a known password.
|
||||
# Use admin API to reset the password so we can generate a new token.
|
||||
_forgejo_exec forgejo admin user change-password \
|
||||
--username "${bot_user}" \
|
||||
--password "${bot_pass}" \
|
||||
--must-change-password=false || {
|
||||
echo "Error: failed to reset password for existing bot user '${bot_user}'" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Generate token via API (basic auth as the bot user — Forgejo requires
|
||||
# basic auth on POST /users/{username}/tokens, token auth is rejected)
|
||||
# First, try to delete existing tokens to avoid name collision
|
||||
# Use bot user's own Basic Auth (we just set the password above)
|
||||
local existing_token_ids
|
||||
existing_token_ids=$(curl -sf \
|
||||
-u "${bot_user}:${bot_pass}" \
|
||||
"${forge_url}/api/v1/users/${bot_user}/tokens" 2>/dev/null \
|
||||
| jq -r '.[].id // empty' 2>/dev/null) || existing_token_ids=""
|
||||
|
||||
# Delete any existing tokens for this user
|
||||
if [ -n "$existing_token_ids" ]; then
|
||||
while IFS= read -r tid; do
|
||||
[ -n "$tid" ] && curl -sf -X DELETE \
|
||||
-u "${bot_user}:${bot_pass}" \
|
||||
"${forge_url}/api/v1/users/${bot_user}/tokens/${tid}" >/dev/null 2>&1 || true
|
||||
done <<< "$existing_token_ids"
|
||||
fi
|
||||
|
||||
token=$(curl -sf -X POST \
|
||||
-u "${bot_user}:${bot_pass}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/users/${bot_user}/tokens" \
|
||||
-d "{\"name\":\"disinto-${bot_user}-token\",\"scopes\":[\"all\"]}" 2>/dev/null \
|
||||
| jq -r '.sha1 // empty') || token=""
|
||||
|
||||
if [ -z "$token" ]; then
|
||||
echo "Error: failed to create API token for '${bot_user}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Store token in .env under the per-agent variable name
|
||||
if grep -q "^${token_var}=" "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^${token_var}=.*|${token_var}=${token}|" "$env_file"
|
||||
else
|
||||
printf '%s=%s\n' "$token_var" "$token" >> "$env_file"
|
||||
fi
|
||||
export "${token_var}=${token}"
|
||||
echo " ${bot_user} token generated and saved (${token_var})"
|
||||
|
||||
# Store password in .env for git HTTP push (#361)
|
||||
# Forgejo 11.x API tokens don't work for git push; password auth does.
|
||||
pass_var="${bot_pass_vars[$bot_user]}"
|
||||
if grep -q "^${pass_var}=" "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^${pass_var}=.*|${pass_var}=${bot_pass}|" "$env_file"
|
||||
else
|
||||
printf '%s=%s\n' "$pass_var" "$bot_pass" >> "$env_file"
|
||||
fi
|
||||
export "${pass_var}=${bot_pass}"
|
||||
echo " ${bot_user} password saved (${pass_var})"
|
||||
|
||||
# Backwards-compat aliases for dev-bot and review-bot
|
||||
if [ "$bot_user" = "dev-bot" ]; then
|
||||
export CODEBERG_TOKEN="$token"
|
||||
elif [ "$bot_user" = "review-bot" ]; then
|
||||
export REVIEW_BOT_TOKEN="$token"
|
||||
fi
|
||||
done
|
||||
|
||||
# Store FORGE_URL in .env if not already present
|
||||
if ! grep -q '^FORGE_URL=' "$env_file" 2>/dev/null; then
|
||||
printf 'FORGE_URL=%s\n' "$forge_url" >> "$env_file"
|
||||
fi
|
||||
|
||||
# Create the repo on Forgejo if it doesn't exist
|
||||
local org_name="${repo_slug%%/*}"
|
||||
local repo_name="${repo_slug##*/}"
|
||||
|
||||
# Check if repo already exists
|
||||
if ! curl -sf --max-time 5 \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${forge_url}/api/v1/repos/${repo_slug}" >/dev/null 2>&1; then
|
||||
|
||||
# Try creating org first (ignore if exists)
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/orgs" \
|
||||
-d "{\"username\":\"${org_name}\",\"visibility\":\"public\"}" >/dev/null 2>&1 || true
|
||||
|
||||
# Create repo under org
|
||||
if ! curl -sf -X POST \
|
||||
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/orgs/${org_name}/repos" \
|
||||
-d "{\"name\":\"${repo_name}\",\"auto_init\":false,\"default_branch\":\"main\"}" >/dev/null 2>&1; then
|
||||
# Fallback: create under the human user namespace using admin endpoint
|
||||
if [ -n "${admin_token:-}" ]; then
|
||||
if ! curl -sf -X POST \
|
||||
-H "Authorization: token ${admin_token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/admin/users/${org_name}/repos" \
|
||||
-d "{\"name\":\"${repo_name}\",\"auto_init\":false,\"default_branch\":\"main\"}" >/dev/null 2>&1; then
|
||||
echo "Error: failed to create repo '${repo_slug}' on Forgejo (admin endpoint)" >&2
|
||||
exit 1
|
||||
fi
|
||||
elif [ -n "${HUMAN_TOKEN:-}" ]; then
|
||||
if ! curl -sf -X POST \
|
||||
-H "Authorization: token ${HUMAN_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/user/repos" \
|
||||
-d "{\"name\":\"${repo_name}\",\"auto_init\":false,\"default_branch\":\"main\"}" >/dev/null 2>&1; then
|
||||
echo "Error: failed to create repo '${repo_slug}' on Forgejo (user endpoint)" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Error: failed to create repo '${repo_slug}' — no admin or human token available" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Add all bot users as collaborators with appropriate permissions
|
||||
# dev-bot: write (PR creation via lib/vault.sh)
|
||||
# review-bot: read (PR review)
|
||||
# planner-bot: write (prerequisites.md, memory)
|
||||
# gardener-bot: write (backlog grooming)
|
||||
# vault-bot: write (vault items)
|
||||
# supervisor-bot: read (health monitoring)
|
||||
# predictor-bot: read (pattern detection)
|
||||
# architect-bot: write (sprint PRs)
|
||||
local bot_perm
|
||||
declare -A bot_permissions=(
|
||||
[dev-bot]="write"
|
||||
[review-bot]="read"
|
||||
[planner-bot]="write"
|
||||
[gardener-bot]="write"
|
||||
[vault-bot]="write"
|
||||
[supervisor-bot]="read"
|
||||
[predictor-bot]="read"
|
||||
[architect-bot]="write"
|
||||
)
|
||||
for bot_user in "${!bot_permissions[@]}"; do
|
||||
bot_perm="${bot_permissions[$bot_user]}"
|
||||
curl -sf -X PUT \
|
||||
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/repos/${repo_slug}/collaborators/${bot_user}" \
|
||||
-d "{\"permission\":\"${bot_perm}\"}" >/dev/null 2>&1 || true
|
||||
done
|
||||
|
||||
# Add disinto-admin as admin collaborator
|
||||
curl -sf -X PUT \
|
||||
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/repos/${repo_slug}/collaborators/disinto-admin" \
|
||||
-d '{"permission":"admin"}' >/dev/null 2>&1 || true
|
||||
|
||||
echo "Repo: ${repo_slug} created on Forgejo"
|
||||
else
|
||||
echo "Repo: ${repo_slug} (already exists on Forgejo)"
|
||||
fi
|
||||
|
||||
echo "Forge: ${forge_url} (ready)"
|
||||
}
|
||||
|
|
@ -1,34 +1,23 @@
|
|||
#!/usr/bin/env bash
|
||||
# formula-session.sh — Shared helpers for formula-driven cron agents
|
||||
#
|
||||
# Provides reusable utility functions for the common cron-wrapper pattern
|
||||
# used by planner-run.sh, predictor-run.sh, gardener-run.sh, and supervisor-run.sh.
|
||||
# Provides reusable functions for the common cron-wrapper + tmux-session
|
||||
# pattern used by planner-run.sh, predictor-run.sh, gardener-run.sh, and supervisor-run.sh.
|
||||
#
|
||||
# Functions:
|
||||
# acquire_cron_lock LOCK_FILE — PID lock with stale cleanup
|
||||
# check_memory [MIN_MB] — skip if available RAM too low
|
||||
# load_formula FORMULA_FILE — sets FORMULA_CONTENT
|
||||
# build_context_block FILE [FILE ...] — sets CONTEXT_BLOCK
|
||||
# build_prompt_footer [EXTRA_API_LINES] — sets PROMPT_FOOTER (API ref + env)
|
||||
# build_sdk_prompt_footer [EXTRA_API] — omits phase protocol (SDK mode)
|
||||
# formula_worktree_setup WORKTREE — isolated worktree for formula execution
|
||||
# formula_prepare_profile_context — load lessons from .profile repo (pre-session)
|
||||
# formula_lessons_block — return lessons block for prompt
|
||||
# profile_write_journal ISSUE_NUM TITLE OUTCOME [FILES] — post-session journal
|
||||
# profile_load_lessons — load lessons-learned.md into LESSONS_CONTEXT
|
||||
# ensure_profile_repo [AGENT_IDENTITY] — clone/pull .profile repo
|
||||
# _profile_has_repo — check if agent has .profile repo
|
||||
# _count_undigested_journals — count journal entries to digest
|
||||
# _profile_digest_journals — digest journals into lessons
|
||||
# _profile_commit_and_push MESSAGE [FILES] — commit/push to .profile repo
|
||||
# resolve_agent_identity — resolve agent user login from FORGE_TOKEN
|
||||
# build_graph_section — run build-graph.py and set GRAPH_SECTION
|
||||
# build_scratch_instruction SCRATCH_FILE — return context scratch instruction
|
||||
# read_scratch_context SCRATCH_FILE — return scratch file content block
|
||||
# ensure_ops_repo — clone/pull ops repo
|
||||
# ops_commit_and_push MESSAGE [FILES] — commit/push to ops repo
|
||||
# cleanup_stale_crashed_worktrees [HOURS] — thin wrapper around worktree_cleanup_stale
|
||||
# start_formula_session SESSION WORKDIR PHASE_FILE — create tmux + claude
|
||||
# build_prompt_footer [EXTRA_API] — sets PROMPT_FOOTER (API ref + env + phase)
|
||||
# run_formula_and_monitor AGENT [TIMEOUT] [CALLBACK] — session start, inject, monitor, log
|
||||
# formula_phase_callback PHASE — standard crash-recovery callback
|
||||
#
|
||||
# Requires: lib/env.sh, lib/worktree.sh sourced first for shared helpers.
|
||||
# Requires: lib/agent-session.sh sourced first (for create_agent_session,
|
||||
# agent_kill_session, agent_inject_into_session).
|
||||
# Globals used by formula_phase_callback: SESSION_NAME, PHASE_FILE,
|
||||
# PROJECT_REPO_ROOT, PROMPT (set by the calling script).
|
||||
|
||||
# ── Cron guards ──────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -50,431 +39,16 @@ acquire_cron_lock() {
|
|||
trap 'rm -f "$_CRON_LOCK_FILE"' EXIT
|
||||
}
|
||||
|
||||
# ── Agent identity resolution ────────────────────────────────────────────
|
||||
|
||||
# resolve_agent_identity
|
||||
# Resolves the agent identity (user login) from the FORGE_TOKEN.
|
||||
# Exports AGENT_IDENTITY (user login string).
|
||||
# Returns 0 on success, 1 on failure.
|
||||
resolve_agent_identity() {
|
||||
if [ -z "${FORGE_TOKEN:-}" ]; then
|
||||
log "WARNING: FORGE_TOKEN not set, cannot resolve agent identity"
|
||||
return 1
|
||||
# check_memory [MIN_MB]
|
||||
# Exits 0 (skip) if available memory is below MIN_MB (default 2000).
|
||||
check_memory() {
|
||||
local min_mb="${1:-2000}"
|
||||
local avail_mb
|
||||
avail_mb=$(free -m | awk '/Mem:/{print $7}')
|
||||
if [ "${avail_mb:-0}" -lt "$min_mb" ]; then
|
||||
log "run: skipping — only ${avail_mb}MB available (need ${min_mb})"
|
||||
exit 0
|
||||
fi
|
||||
local forge_url="${FORGE_URL:-http://localhost:3000}"
|
||||
AGENT_IDENTITY=$(curl -sf --max-time 10 \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${forge_url}/api/v1/user" 2>/dev/null | jq -r '.login // empty' 2>/dev/null) || true
|
||||
if [ -z "$AGENT_IDENTITY" ]; then
|
||||
log "WARNING: failed to resolve agent identity from FORGE_TOKEN"
|
||||
return 1
|
||||
fi
|
||||
log "Resolved agent identity: ${AGENT_IDENTITY}"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Forge remote resolution ──────────────────────────────────────────────
|
||||
|
||||
# resolve_forge_remote
|
||||
# Resolves FORGE_REMOTE by matching FORGE_URL hostname against git remotes.
|
||||
# Falls back to "origin" if no match found.
|
||||
# Requires: FORGE_URL, git repo with remotes configured.
|
||||
# Exports: FORGE_REMOTE (always set).
|
||||
resolve_forge_remote() {
|
||||
# Extract hostname from FORGE_URL (e.g., https://codeberg.org/user/repo -> codeberg.org)
|
||||
_forge_host=$(printf '%s' "$FORGE_URL" | sed 's|https\?://||; s|/.*||; s|:.*||')
|
||||
# Find git remote whose push URL matches the forge host
|
||||
FORGE_REMOTE=$(git remote -v | awk -v host="$_forge_host" '$2 ~ host && /\(push\)/ {print $1; exit}')
|
||||
# Fallback to origin if no match found
|
||||
FORGE_REMOTE="${FORGE_REMOTE:-origin}"
|
||||
export FORGE_REMOTE
|
||||
log "forge remote: ${FORGE_REMOTE}"
|
||||
}
|
||||
|
||||
# ── .profile repo management ──────────────────────────────────────────────
|
||||
|
||||
# ensure_profile_repo [AGENT_IDENTITY]
|
||||
# Clones or pulls the agent's .profile repo to a local cache dir.
|
||||
# Requires: FORGE_TOKEN, FORGE_URL.
|
||||
# Exports PROFILE_REPO_PATH (local cache path) and PROFILE_FORMULA_PATH.
|
||||
# Returns 0 on success, 1 on failure (falls back gracefully).
|
||||
ensure_profile_repo() {
|
||||
local agent_identity="${1:-${AGENT_IDENTITY:-}}"
|
||||
|
||||
if [ -z "$agent_identity" ]; then
|
||||
# Try to resolve from FORGE_TOKEN
|
||||
if ! resolve_agent_identity; then
|
||||
log "WARNING: cannot resolve agent identity, skipping .profile repo"
|
||||
return 1
|
||||
fi
|
||||
agent_identity="$AGENT_IDENTITY"
|
||||
fi
|
||||
|
||||
# Define cache directory: /home/agent/data/.profile/{agent-name}
|
||||
PROFILE_REPO_PATH="${HOME:-/home/agent}/data/.profile/${agent_identity}"
|
||||
|
||||
# Build clone URL from FORGE_URL and agent identity
|
||||
local forge_url="${FORGE_URL:-http://localhost:3000}"
|
||||
local auth_url
|
||||
auth_url=$(printf '%s' "$forge_url" | sed "s|://|://$(whoami):${FORGE_TOKEN}@|")
|
||||
local clone_url="${auth_url}/${agent_identity}/.profile.git"
|
||||
|
||||
# Check if already cached and up-to-date
|
||||
if [ -d "${PROFILE_REPO_PATH}/.git" ]; then
|
||||
log "Pulling .profile repo: ${agent_identity}/.profile"
|
||||
if git -C "$PROFILE_REPO_PATH" fetch origin --quiet 2>/dev/null; then
|
||||
git -C "$PROFILE_REPO_PATH" checkout main --quiet 2>/dev/null || \
|
||||
git -C "$PROFILE_REPO_PATH" checkout master --quiet 2>/dev/null || true
|
||||
git -C "$PROFILE_REPO_PATH" pull --ff-only origin main --quiet 2>/dev/null || \
|
||||
git -C "$PROFILE_REPO_PATH" pull --ff-only origin master --quiet 2>/dev/null || true
|
||||
log ".profile repo pulled: ${PROFILE_REPO_PATH}"
|
||||
else
|
||||
log "WARNING: failed to pull .profile repo, using cached version"
|
||||
fi
|
||||
else
|
||||
log "Cloning .profile repo: ${agent_identity}/.profile -> ${PROFILE_REPO_PATH}"
|
||||
if git clone --quiet "$clone_url" "$PROFILE_REPO_PATH" 2>/dev/null; then
|
||||
log ".profile repo cloned: ${PROFILE_REPO_PATH}"
|
||||
else
|
||||
log "WARNING: failed to clone .profile repo ${agent_identity}/.profile — falling back to formulas/"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Set formula path from .profile
|
||||
PROFILE_FORMULA_PATH="${PROFILE_REPO_PATH}/formula.toml"
|
||||
return 0
|
||||
}
|
||||
|
||||
# _profile_has_repo
|
||||
# Checks if the agent has a .profile repo by querying Forgejo API.
|
||||
# Returns 0 if repo exists, 1 otherwise.
|
||||
_profile_has_repo() {
|
||||
local agent_identity="${AGENT_IDENTITY:-}"
|
||||
|
||||
if [ -z "$agent_identity" ]; then
|
||||
if ! resolve_agent_identity; then
|
||||
return 1
|
||||
fi
|
||||
agent_identity="$AGENT_IDENTITY"
|
||||
fi
|
||||
|
||||
local forge_url="${FORGE_URL:-http://localhost:3000}"
|
||||
local api_url="${forge_url}/api/v1/repos/${agent_identity}/.profile"
|
||||
|
||||
# Check if repo exists via API (returns 200 if exists, 404 if not)
|
||||
if curl -sf -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"$api_url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# _count_undigested_journals
|
||||
# Counts journal entries in .profile/journal/ excluding archive/
|
||||
# Returns count via stdout.
|
||||
_count_undigested_journals() {
|
||||
if [ ! -d "${PROFILE_REPO_PATH:-}/journal" ]; then
|
||||
echo "0"
|
||||
return
|
||||
fi
|
||||
find "${PROFILE_REPO_PATH}/journal" -maxdepth 1 -name "*.md" -type f ! -path "*/archive/*" 2>/dev/null | wc -l
|
||||
}
|
||||
|
||||
# _profile_digest_journals
|
||||
# Runs a claude -p one-shot to digest undigested journals into lessons-learned.md
|
||||
# Returns 0 on success, 1 on failure.
|
||||
_profile_digest_journals() {
|
||||
local agent_identity="${AGENT_IDENTITY:-}"
|
||||
local model="${CLAUDE_MODEL:-opus}"
|
||||
|
||||
if [ -z "$agent_identity" ]; then
|
||||
if ! resolve_agent_identity; then
|
||||
return 1
|
||||
fi
|
||||
agent_identity="$AGENT_IDENTITY"
|
||||
fi
|
||||
|
||||
local journal_dir="${PROFILE_REPO_PATH}/journal"
|
||||
local knowledge_dir="${PROFILE_REPO_PATH}/knowledge"
|
||||
local lessons_file="${knowledge_dir}/lessons-learned.md"
|
||||
|
||||
# Collect undigested journal entries
|
||||
local journal_entries=""
|
||||
if [ -d "$journal_dir" ]; then
|
||||
for jf in "$journal_dir"/*.md; do
|
||||
[ -f "$jf" ] || continue
|
||||
# Skip archived entries
|
||||
[[ "$jf" == */archive/* ]] && continue
|
||||
local basename
|
||||
basename=$(basename "$jf")
|
||||
journal_entries="${journal_entries}
|
||||
### ${basename}
|
||||
$(cat "$jf")
|
||||
"
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -z "$journal_entries" ]; then
|
||||
log "profile: no undigested journals to digest"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Read existing lessons if available
|
||||
local existing_lessons=""
|
||||
if [ -f "$lessons_file" ]; then
|
||||
existing_lessons=$(cat "$lessons_file")
|
||||
fi
|
||||
|
||||
# Build prompt for digestion
|
||||
local digest_prompt="You are digesting journal entries from a developer agent's work sessions.
|
||||
|
||||
## Task
|
||||
Condense these journal entries into abstract, transferable lessons. Rewrite lessons-learned.md entirely.
|
||||
|
||||
## Constraints
|
||||
- Hard cap: 2KB maximum
|
||||
- Abstract: patterns and heuristics, not specific issues or file paths
|
||||
- Transferable: must help with future unseen work, not just recall past work
|
||||
- Drop the least transferable lessons if over limit
|
||||
|
||||
## Existing lessons-learned.md (if any)
|
||||
${existing_lessons:-<none>}
|
||||
|
||||
## Journal entries to digest
|
||||
${journal_entries}
|
||||
|
||||
## Output
|
||||
Write the complete, rewritten lessons-learned.md content below. No preamble, no explanation — just the file content."
|
||||
|
||||
# Run claude -p one-shot with same model as agent
|
||||
local output
|
||||
output=$(claude -p "$digest_prompt" \
|
||||
--output-format json \
|
||||
--dangerously-skip-permissions \
|
||||
${model:+--model "$model"} \
|
||||
2>>"$LOGFILE" || echo '{"result":"error"}')
|
||||
|
||||
# Extract content from JSON response
|
||||
local lessons_content
|
||||
lessons_content=$(printf '%s' "$output" | jq -r '.result // empty' 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$lessons_content" ]; then
|
||||
log "profile: failed to digest journals"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Ensure knowledge directory exists
|
||||
mkdir -p "$knowledge_dir"
|
||||
|
||||
# Write the lessons file (full rewrite)
|
||||
printf '%s\n' "$lessons_content" > "$lessons_file"
|
||||
log "profile: wrote lessons-learned.md (${#lessons_content} bytes)"
|
||||
|
||||
# Move digested journals to archive (if any were processed)
|
||||
if [ -d "$journal_dir" ]; then
|
||||
mkdir -p "${journal_dir}/archive"
|
||||
local archived=0
|
||||
for jf in "$journal_dir"/*.md; do
|
||||
[ -f "$jf" ] || continue
|
||||
[[ "$jf" == */archive/* ]] && continue
|
||||
local basename
|
||||
basename=$(basename "$jf")
|
||||
mv "$jf" "${journal_dir}/archive/${basename}" 2>/dev/null && archived=$((archived + 1))
|
||||
done
|
||||
if [ "$archived" -gt 0 ]; then
|
||||
log "profile: archived ${archived} journal entries"
|
||||
fi
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# _profile_commit_and_push MESSAGE [FILE ...]
|
||||
# Commits and pushes changes to .profile repo.
|
||||
_profile_commit_and_push() {
|
||||
local msg="$1"
|
||||
shift
|
||||
local files=("$@")
|
||||
|
||||
if [ ! -d "${PROFILE_REPO_PATH:-}/.git" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
(
|
||||
cd "$PROFILE_REPO_PATH" || return 1
|
||||
|
||||
if [ ${#files[@]} -gt 0 ]; then
|
||||
git add "${files[@]}"
|
||||
else
|
||||
git add -A
|
||||
fi
|
||||
|
||||
if ! git diff --cached --quiet 2>/dev/null; then
|
||||
git config user.name "${AGENT_IDENTITY}" || true
|
||||
git config user.email "${AGENT_IDENTITY}@users.noreply.codeberg.org" || true
|
||||
git commit -m "$msg" --no-verify 2>/dev/null || true
|
||||
git push origin main --quiet 2>/dev/null || git push origin master --quiet 2>/dev/null || true
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
# profile_load_lessons
|
||||
# Pre-session: loads lessons-learned.md into LESSONS_CONTEXT for prompt injection.
|
||||
# Lazy digestion: if >10 undigested journals exist, runs claude -p to digest them.
|
||||
# Returns 0 on success, 1 if agent has no .profile repo (silent no-op).
|
||||
# Requires: ensure_profile_repo() called, AGENT_IDENTITY, FORGE_TOKEN, FORGE_URL, CLAUDE_MODEL.
|
||||
# Exports: LESSONS_CONTEXT (the lessons file content, hard-capped at 2KB).
|
||||
profile_load_lessons() {
|
||||
# Check if agent has .profile repo
|
||||
if ! _profile_has_repo; then
|
||||
return 0 # Silent no-op
|
||||
fi
|
||||
|
||||
# Pull .profile repo
|
||||
if ! ensure_profile_repo; then
|
||||
return 0 # Silent no-op
|
||||
fi
|
||||
|
||||
# Check journal count for lazy digestion trigger
|
||||
local journal_count
|
||||
journal_count=$(_count_undigested_journals)
|
||||
|
||||
if [ "${journal_count:-0}" -gt 10 ]; then
|
||||
log "profile: digesting ${journal_count} undigested journals"
|
||||
if ! _profile_digest_journals; then
|
||||
log "profile: warning — journal digestion failed"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Read lessons-learned.md (hard cap at 2KB)
|
||||
local lessons_file="${PROFILE_REPO_PATH}/knowledge/lessons-learned.md"
|
||||
LESSONS_CONTEXT=""
|
||||
|
||||
if [ -f "$lessons_file" ]; then
|
||||
local lessons_content
|
||||
lessons_content=$(head -c 2048 "$lessons_file" 2>/dev/null) || lessons_content=""
|
||||
if [ -n "$lessons_content" ]; then
|
||||
# shellcheck disable=SC2034 # exported to caller for prompt injection
|
||||
LESSONS_CONTEXT="## Lessons learned (from .profile/knowledge/lessons-learned.md)
|
||||
${lessons_content}"
|
||||
log "profile: loaded lessons-learned.md (${#lessons_content} bytes)"
|
||||
fi
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# formula_prepare_profile_context
|
||||
# Pre-session: loads lessons from .profile repo and sets LESSONS_CONTEXT for prompt injection.
|
||||
# Single shared function to avoid duplicate boilerplate across agent scripts.
|
||||
# Requires: AGENT_IDENTITY, FORGE_TOKEN, FORGE_URL (via profile_load_lessons).
|
||||
# Exports: LESSONS_CONTEXT (set by profile_load_lessons).
|
||||
# Returns 0 on success, 1 if agent has no .profile repo (silent no-op).
|
||||
formula_prepare_profile_context() {
|
||||
profile_load_lessons || true
|
||||
LESSONS_INJECTION="${LESSONS_CONTEXT:-}"
|
||||
}
|
||||
|
||||
# formula_lessons_block
|
||||
# Returns a formatted lessons block for prompt injection.
|
||||
# Usage: LESSONS_BLOCK=$(formula_lessons_block)
|
||||
# Expects: LESSONS_INJECTION to be set by formula_prepare_profile_context.
|
||||
# Returns: formatted block or empty string.
|
||||
formula_lessons_block() {
|
||||
if [ -n "${LESSONS_INJECTION:-}" ]; then
|
||||
printf '\n## Lessons learned (from .profile/knowledge/lessons-learned.md)\n%s' "$LESSONS_INJECTION"
|
||||
fi
|
||||
}
|
||||
|
||||
# profile_write_journal ISSUE_NUM ISSUE_TITLE OUTCOME [FILES_CHANGED]
|
||||
# Post-session: writes a reflection journal entry after work completes.
|
||||
# Returns 0 on success, 1 on failure.
|
||||
# Requires: AGENT_IDENTITY, FORGE_TOKEN, FORGE_URL, CLAUDE_MODEL.
|
||||
# Args:
|
||||
# $1 - ISSUE_NUM: The issue number worked on
|
||||
# $2 - ISSUE_TITLE: The issue title
|
||||
# $3 - OUTCOME: Session outcome (merged, blocked, failed, etc.)
|
||||
# $4 - FILES_CHANGED: Optional comma-separated list of files changed
|
||||
profile_write_journal() {
|
||||
local issue_num="$1"
|
||||
local issue_title="$2"
|
||||
local outcome="$3"
|
||||
local files_changed="${4:-}"
|
||||
|
||||
# Check if agent has .profile repo
|
||||
if ! _profile_has_repo; then
|
||||
return 0 # Silent no-op
|
||||
fi
|
||||
|
||||
# Pull .profile repo
|
||||
if ! ensure_profile_repo; then
|
||||
return 0 # Silent no-op
|
||||
fi
|
||||
|
||||
# Build session summary
|
||||
local session_summary=""
|
||||
if [ -n "$files_changed" ]; then
|
||||
session_summary="Files changed: ${files_changed}
|
||||
"
|
||||
fi
|
||||
session_summary="${session_summary}Outcome: ${outcome}"
|
||||
|
||||
# Build reflection prompt
|
||||
local reflection_prompt="You are reflecting on a development session. Write a concise journal entry about transferable lessons learned.
|
||||
|
||||
## Session context
|
||||
- Issue: #${issue_num} — ${issue_title}
|
||||
- Outcome: ${outcome}
|
||||
|
||||
${session_summary}
|
||||
|
||||
## Task
|
||||
Write a journal entry focused on what you learned that would help you do similar work better next time.
|
||||
|
||||
## Constraints
|
||||
- Be concise (100-200 words)
|
||||
- Focus on transferable lessons, not a summary of what you did
|
||||
- Abstract patterns and heuristics, not specific issue/file references
|
||||
- One concise entry, not a list
|
||||
|
||||
## Output
|
||||
Write the journal entry below. Use markdown format."
|
||||
|
||||
# Run claude -p one-shot with same model as agent
|
||||
local output
|
||||
output=$(claude -p "$reflection_prompt" \
|
||||
--output-format json \
|
||||
--dangerously-skip-permissions \
|
||||
${CLAUDE_MODEL:+--model "$CLAUDE_MODEL"} \
|
||||
2>>"$LOGFILE" || echo '{"result":"error"}')
|
||||
|
||||
# Extract content from JSON response
|
||||
local journal_content
|
||||
journal_content=$(printf '%s' "$output" | jq -r '.result // empty' 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$journal_content" ]; then
|
||||
log "profile: failed to write journal entry"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Ensure journal directory exists
|
||||
local journal_dir="${PROFILE_REPO_PATH}/journal"
|
||||
mkdir -p "$journal_dir"
|
||||
|
||||
# Write journal entry (append if exists)
|
||||
local journal_file="${journal_dir}/issue-${issue_num}.md"
|
||||
if [ -f "$journal_file" ]; then
|
||||
printf '\n---\n\n' >> "$journal_file"
|
||||
fi
|
||||
printf '%s\n' "$journal_content" >> "$journal_file"
|
||||
log "profile: wrote journal entry for issue #${issue_num}"
|
||||
|
||||
# Commit and push to .profile repo
|
||||
_profile_commit_and_push "journal: issue #${issue_num} reflection" "journal/issue-${issue_num}.md"
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Formula loading ──────────────────────────────────────────────────────
|
||||
|
|
@ -491,60 +65,6 @@ load_formula() {
|
|||
FORMULA_CONTENT=$(cat "$formula_file")
|
||||
}
|
||||
|
||||
# load_formula_or_profile [ROLE] [FORMULA_FILE]
|
||||
# Tries to load formula from .profile repo first, falls back to formulas/<role>.toml.
|
||||
# Requires: AGENT_IDENTITY, ensure_profile_repo() available.
|
||||
# Exports: FORMULA_CONTENT, FORMULA_SOURCE (either ".profile" or "formulas/").
|
||||
# Returns 0 on success, 1 on failure.
|
||||
load_formula_or_profile() {
|
||||
local role="${1:-}"
|
||||
local fallback_formula="${2:-}"
|
||||
|
||||
# Try to load from .profile repo
|
||||
if [ -n "$AGENT_IDENTITY" ] && ensure_profile_repo "$AGENT_IDENTITY"; then
|
||||
if [ -f "$PROFILE_FORMULA_PATH" ]; then
|
||||
log "formula source: .profile (${PROFILE_FORMULA_PATH})"
|
||||
# shellcheck disable=SC2034
|
||||
FORMULA_CONTENT="$(cat "$PROFILE_FORMULA_PATH")"
|
||||
FORMULA_SOURCE=".profile"
|
||||
return 0
|
||||
else
|
||||
log "WARNING: .profile repo exists but formula.toml not found at ${PROFILE_FORMULA_PATH}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback to formulas/<role>.toml
|
||||
if [ -n "$fallback_formula" ]; then
|
||||
if [ -f "$fallback_formula" ]; then
|
||||
log "formula source: formulas/ (fallback) — ${fallback_formula}"
|
||||
# shellcheck disable=SC2034
|
||||
FORMULA_CONTENT="$(cat "$fallback_formula")"
|
||||
FORMULA_SOURCE="formulas/"
|
||||
return 0
|
||||
else
|
||||
log "ERROR: formula not found in .profile and fallback file not found: $fallback_formula"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# No fallback specified but role provided — construct fallback path
|
||||
if [ -n "$role" ]; then
|
||||
fallback_formula="${FACTORY_ROOT}/formulas/${role}.toml"
|
||||
if [ -f "$fallback_formula" ]; then
|
||||
log "formula source: formulas/ (fallback) — ${fallback_formula}"
|
||||
# shellcheck disable=SC2034
|
||||
FORMULA_CONTENT="$(cat "$fallback_formula")"
|
||||
# shellcheck disable=SC2034
|
||||
FORMULA_SOURCE="formulas/"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# No fallback specified
|
||||
log "ERROR: formula not found in .profile and no fallback specified"
|
||||
return 1
|
||||
}
|
||||
|
||||
# build_context_block FILE [FILE ...]
|
||||
# Reads each file from $PROJECT_REPO_ROOT and builds CONTEXT_BLOCK.
|
||||
# Files prefixed with "ops:" are read from $OPS_REPO_ROOT instead.
|
||||
|
|
@ -571,7 +91,7 @@ $(cat "$ctx_path")
|
|||
done
|
||||
}
|
||||
|
||||
# ── Ops repo helpers ────────────────────────────────────────────────────
|
||||
# ── Ops repo helpers ─────────────────────────────────────────────────
|
||||
|
||||
# ensure_ops_repo
|
||||
# Clones or pulls the ops repo so agents can read/write operational data.
|
||||
|
|
@ -634,6 +154,127 @@ ops_commit_and_push() {
|
|||
)
|
||||
}
|
||||
|
||||
# ── Session management ───────────────────────────────────────────────────
|
||||
|
||||
# start_formula_session SESSION WORKDIR PHASE_FILE
|
||||
# Kills stale session, resets phase file, creates a per-agent git worktree
|
||||
# for session isolation, and creates a new tmux + claude session in it.
|
||||
# Sets _FORMULA_SESSION_WORKDIR to the worktree path (or original workdir
|
||||
# on fallback). Callers must clean up via remove_formula_worktree after
|
||||
# the session ends.
|
||||
# Returns 0 on success, 1 on failure.
|
||||
start_formula_session() {
|
||||
local session="$1" workdir="$2" phase_file="$3"
|
||||
agent_kill_session "$session"
|
||||
rm -f "$phase_file"
|
||||
|
||||
# Create per-agent git worktree for session isolation.
|
||||
# Each agent gets its own CWD so Claude Code treats them as separate
|
||||
# projects — no resume collisions between sequential formula runs.
|
||||
_FORMULA_SESSION_WORKDIR="/tmp/disinto-${session}"
|
||||
# Clean up any stale worktree from a previous run
|
||||
git -C "$workdir" worktree remove "$_FORMULA_SESSION_WORKDIR" --force 2>/dev/null || true
|
||||
if git -C "$workdir" worktree add "$_FORMULA_SESSION_WORKDIR" HEAD --detach 2>/dev/null; then
|
||||
log "Created worktree: ${_FORMULA_SESSION_WORKDIR}"
|
||||
else
|
||||
log "WARNING: worktree creation failed — falling back to ${workdir}"
|
||||
_FORMULA_SESSION_WORKDIR="$workdir"
|
||||
fi
|
||||
|
||||
log "Creating tmux session: ${session}"
|
||||
if ! create_agent_session "$session" "$_FORMULA_SESSION_WORKDIR" "$phase_file"; then
|
||||
log "ERROR: failed to create tmux session ${session}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# remove_formula_worktree
|
||||
# Removes the worktree created by start_formula_session if it differs from
|
||||
# PROJECT_REPO_ROOT. Safe to call multiple times. No-op if no worktree was created.
|
||||
remove_formula_worktree() {
|
||||
if [ -n "${_FORMULA_SESSION_WORKDIR:-}" ] \
|
||||
&& [ "$_FORMULA_SESSION_WORKDIR" != "${PROJECT_REPO_ROOT:-}" ]; then
|
||||
git -C "$PROJECT_REPO_ROOT" worktree remove "$_FORMULA_SESSION_WORKDIR" --force 2>/dev/null || true
|
||||
log "Removed worktree: ${_FORMULA_SESSION_WORKDIR}"
|
||||
fi
|
||||
}
|
||||
|
||||
# formula_phase_callback PHASE
|
||||
# Standard crash-recovery phase callback for formula sessions.
|
||||
# Requires globals: SESSION_NAME, PHASE_FILE, PROJECT_REPO_ROOT, PROMPT.
|
||||
# Uses _FORMULA_CRASH_COUNT (auto-initialized) for single-retry limit.
|
||||
# shellcheck disable=SC2154 # SESSION_NAME, PHASE_FILE, PROJECT_REPO_ROOT, PROMPT set by caller
|
||||
formula_phase_callback() {
|
||||
local phase="$1"
|
||||
log "phase: ${phase}"
|
||||
case "$phase" in
|
||||
PHASE:crashed)
|
||||
if [ "${_FORMULA_CRASH_COUNT:-0}" -gt 0 ]; then
|
||||
log "ERROR: session crashed again after recovery — giving up"
|
||||
return 0
|
||||
fi
|
||||
_FORMULA_CRASH_COUNT=$(( ${_FORMULA_CRASH_COUNT:-0} + 1 ))
|
||||
log "WARNING: tmux session died unexpectedly — attempting recovery"
|
||||
if create_agent_session "${_MONITOR_SESSION:-$SESSION_NAME}" "${_FORMULA_SESSION_WORKDIR:-$PROJECT_REPO_ROOT}" "$PHASE_FILE" 2>/dev/null; then
|
||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" "$PROMPT"
|
||||
log "Recovery session started"
|
||||
else
|
||||
log "ERROR: could not restart session after crash"
|
||||
fi
|
||||
;;
|
||||
PHASE:done|PHASE:failed|PHASE:escalate|PHASE:merged)
|
||||
agent_kill_session "${_MONITOR_SESSION:-$SESSION_NAME}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── Stale crashed worktree cleanup ─────────────────────────────────────────
|
||||
|
||||
# cleanup_stale_crashed_worktrees [MAX_AGE_HOURS]
|
||||
# Removes preserved crashed worktrees older than MAX_AGE_HOURS (default 24).
|
||||
# Scans /tmp for orphaned worktrees matching agent naming patterns.
|
||||
# Safe to call from any agent; intended for supervisor/gardener housekeeping.
|
||||
# Requires globals: PROJECT_REPO_ROOT.
|
||||
cleanup_stale_crashed_worktrees() {
|
||||
local max_age_hours="${1:-24}"
|
||||
local max_age_seconds=$((max_age_hours * 3600))
|
||||
local now
|
||||
now=$(date +%s)
|
||||
local cleaned=0
|
||||
|
||||
# Collect active tmux pane working directories for safety check
|
||||
local active_dirs=""
|
||||
active_dirs=$(tmux list-panes -a -F '#{pane_current_path}' 2>/dev/null || true)
|
||||
|
||||
local wt_dir
|
||||
for wt_dir in /tmp/*-worktree-* /tmp/action-*-[0-9]* /tmp/disinto-*; do
|
||||
[ -d "$wt_dir" ] || continue
|
||||
# Must be a git worktree (has .git file or directory)
|
||||
[ -f "$wt_dir/.git" ] || [ -d "$wt_dir/.git" ] || continue
|
||||
|
||||
# Check age (use directory mtime)
|
||||
local dir_mtime
|
||||
dir_mtime=$(stat -c %Y "$wt_dir" 2>/dev/null || echo "$now")
|
||||
local age=$((now - dir_mtime))
|
||||
[ "$age" -lt "$max_age_seconds" ] && continue
|
||||
|
||||
# Skip if an active tmux pane is using this worktree
|
||||
if [ -n "$active_dirs" ] && echo "$active_dirs" | grep -qF "$wt_dir"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Remove the worktree
|
||||
git -C "${PROJECT_REPO_ROOT}" worktree remove "$wt_dir" --force 2>/dev/null || rm -rf "$wt_dir"
|
||||
log "cleaned stale crashed worktree: ${wt_dir} (age: $((age / 3600))h)"
|
||||
cleaned=$((cleaned + 1))
|
||||
done
|
||||
|
||||
# Prune any dangling worktree references
|
||||
git -C "${PROJECT_REPO_ROOT}" worktree prune 2>/dev/null || true
|
||||
|
||||
[ "$cleaned" -gt 0 ] && log "cleaned ${cleaned} stale crashed worktree(s)"
|
||||
}
|
||||
|
||||
# ── Scratch file helpers (compaction survival) ────────────────────────────
|
||||
|
||||
# build_scratch_instruction SCRATCH_FILE
|
||||
|
|
@ -679,56 +320,22 @@ build_graph_section() {
|
|||
--project-root "$PROJECT_REPO_ROOT" \
|
||||
--output "$report" 2>>"$LOG_FILE"; then
|
||||
# shellcheck disable=SC2034
|
||||
local report_content
|
||||
report_content="$(cat "$report")"
|
||||
# shellcheck disable=SC2034
|
||||
GRAPH_SECTION="
|
||||
## Structural analysis
|
||||
\`\`\`json
|
||||
${report_content}
|
||||
\`\`\`"
|
||||
GRAPH_SECTION=$(printf '\n## Structural analysis\n```json\n%s\n```\n' \
|
||||
"$(cat "$report")")
|
||||
log "graph report generated: $(jq -r '.stats | "\(.nodes) nodes, \(.edges) edges"' "$report")"
|
||||
else
|
||||
log "WARN: build-graph.py failed — continuing without structural analysis"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── SDK helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
# build_sdk_prompt_footer [EXTRA_API_LINES]
|
||||
# Like build_prompt_footer but omits the phase protocol section (SDK mode).
|
||||
# Sets PROMPT_FOOTER.
|
||||
build_sdk_prompt_footer() {
|
||||
# shellcheck disable=SC2034 # consumed by build_prompt_footer
|
||||
PHASE_FILE="" # not used in SDK mode
|
||||
build_prompt_footer "${1:-}"
|
||||
PROMPT_FOOTER="${PROMPT_FOOTER%%## Phase protocol*}"
|
||||
}
|
||||
|
||||
# formula_worktree_setup WORKTREE
|
||||
# Creates an isolated worktree for synchronous formula execution.
|
||||
# Fetches primary branch, cleans stale worktree, creates new one, and
|
||||
# sets an EXIT trap for cleanup.
|
||||
# Requires globals: PROJECT_REPO_ROOT, PRIMARY_BRANCH, FORGE_REMOTE.
|
||||
# Ensure resolve_forge_remote() is called before this function.
|
||||
formula_worktree_setup() {
|
||||
local worktree="$1"
|
||||
cd "$PROJECT_REPO_ROOT" || return
|
||||
git fetch "${FORGE_REMOTE}" "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
worktree_cleanup "$worktree"
|
||||
git worktree add "$worktree" "${FORGE_REMOTE}/${PRIMARY_BRANCH}" --detach 2>/dev/null
|
||||
# shellcheck disable=SC2064 # expand worktree now, not at trap time
|
||||
trap "worktree_cleanup '$worktree'" EXIT
|
||||
}
|
||||
|
||||
# ── Prompt helpers ──────────────────────────────────────────────────────
|
||||
# ── Prompt + monitor helpers ──────────────────────────────────────────────
|
||||
|
||||
# build_prompt_footer [EXTRA_API_LINES]
|
||||
# Assembles the common forge API reference + environment block for formula prompts.
|
||||
# Sets PROMPT_FOOTER.
|
||||
# Assembles the common forge API reference + environment + phase protocol
|
||||
# block for formula prompts. Sets PROMPT_FOOTER.
|
||||
# Pass additional API endpoint lines (pre-formatted, newline-prefixed) via $1.
|
||||
# Requires globals: FORGE_API, FACTORY_ROOT, PROJECT_REPO_ROOT,
|
||||
# PRIMARY_BRANCH.
|
||||
# PRIMARY_BRANCH, PHASE_FILE.
|
||||
build_prompt_footer() {
|
||||
local extra_api="${1:-}"
|
||||
# shellcheck disable=SC2034 # consumed by the calling script's PROMPT
|
||||
|
|
@ -744,15 +351,66 @@ NEVER echo or include the actual token value in output — always reference \${F
|
|||
FACTORY_ROOT=${FACTORY_ROOT}
|
||||
PROJECT_REPO_ROOT=${PROJECT_REPO_ROOT}
|
||||
OPS_REPO_ROOT=${OPS_REPO_ROOT}
|
||||
PRIMARY_BRANCH=${PRIMARY_BRANCH}"
|
||||
PRIMARY_BRANCH=${PRIMARY_BRANCH}
|
||||
PHASE_FILE=${PHASE_FILE}
|
||||
|
||||
## Phase protocol (REQUIRED)
|
||||
When all work is done:
|
||||
echo 'PHASE:done' > '${PHASE_FILE}'
|
||||
On unrecoverable error:
|
||||
printf 'PHASE:failed\nReason: %s\n' 'describe error' > '${PHASE_FILE}'"
|
||||
}
|
||||
|
||||
# ── Stale crashed worktree cleanup ────────────────────────────────────────
|
||||
# run_formula_and_monitor AGENT_NAME [TIMEOUT]
|
||||
# Starts the formula session, injects PROMPT, monitors phase, and logs result.
|
||||
# Requires globals: SESSION_NAME, PHASE_FILE, PROJECT_REPO_ROOT, PROMPT,
|
||||
# FORGE_REPO, CLAUDE_MODEL (exported).
|
||||
# shellcheck disable=SC2154 # SESSION_NAME, PHASE_FILE, PROJECT_REPO_ROOT, PROMPT set by caller
|
||||
run_formula_and_monitor() {
|
||||
local agent_name="$1"
|
||||
local timeout="${2:-7200}"
|
||||
local callback="${3:-formula_phase_callback}"
|
||||
|
||||
# cleanup_stale_crashed_worktrees [MAX_AGE_HOURS]
|
||||
# Thin wrapper around worktree_cleanup_stale() from lib/worktree.sh.
|
||||
# Kept for backwards compatibility with existing callers.
|
||||
# Requires: lib/worktree.sh sourced.
|
||||
cleanup_stale_crashed_worktrees() {
|
||||
worktree_cleanup_stale "${1:-24}"
|
||||
if ! start_formula_session "$SESSION_NAME" "$PROJECT_REPO_ROOT" "$PHASE_FILE"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Write phase protocol to context file for compaction survival
|
||||
if [ -n "${PROMPT_FOOTER:-}" ]; then
|
||||
write_compact_context "$PHASE_FILE" "$PROMPT_FOOTER"
|
||||
fi
|
||||
|
||||
agent_inject_into_session "$SESSION_NAME" "$PROMPT"
|
||||
log "Prompt sent to tmux session"
|
||||
|
||||
log "Monitoring phase file: ${PHASE_FILE}"
|
||||
_FORMULA_CRASH_COUNT=0
|
||||
|
||||
monitor_phase_loop "$PHASE_FILE" "$timeout" "$callback"
|
||||
|
||||
FINAL_PHASE=$(read_phase "$PHASE_FILE")
|
||||
log "Final phase: ${FINAL_PHASE:-none}"
|
||||
|
||||
if [ "$FINAL_PHASE" != "PHASE:done" ]; then
|
||||
case "${_MONITOR_LOOP_EXIT:-}" in
|
||||
idle_prompt)
|
||||
log "${agent_name}: Claude returned to prompt without writing phase signal"
|
||||
;;
|
||||
idle_timeout)
|
||||
log "${agent_name}: timed out with no phase signal"
|
||||
;;
|
||||
*)
|
||||
log "${agent_name} finished without PHASE:done (phase: ${FINAL_PHASE:-none}, exit: ${_MONITOR_LOOP_EXIT:-})"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Preserve worktree on crash for debugging; clean up on success
|
||||
if [ "${_MONITOR_LOOP_EXIT:-}" = "crashed" ]; then
|
||||
log "PRESERVED crashed worktree for debugging: ${_FORMULA_SESSION_WORKDIR:-}"
|
||||
else
|
||||
remove_formula_worktree
|
||||
fi
|
||||
|
||||
log "--- ${agent_name^} run done ---"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,432 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# generators — template generation functions for disinto init
|
||||
#
|
||||
# Generates docker-compose.yml, Dockerfile, Caddyfile, staging index, and
|
||||
# deployment pipeline configs.
|
||||
#
|
||||
# Globals expected (must be set before sourcing):
|
||||
# FACTORY_ROOT - Root of the disinto factory
|
||||
# PROJECT_NAME - Project name for the project repo (defaults to 'project')
|
||||
# PRIMARY_BRANCH - Primary branch name (defaults to 'main')
|
||||
#
|
||||
# Usage:
|
||||
# source "${FACTORY_ROOT}/lib/generators.sh"
|
||||
# generate_compose "$forge_port"
|
||||
# generate_caddyfile
|
||||
# generate_staging_index
|
||||
# generate_deploy_pipelines "$repo_root" "$project_name"
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# Assert required globals are set
|
||||
: "${FACTORY_ROOT:?FACTORY_ROOT must be set}"
|
||||
# PROJECT_NAME defaults to 'project' if not set (env.sh may have set it from FORGE_REPO)
|
||||
PROJECT_NAME="${PROJECT_NAME:-project}"
|
||||
# PRIMARY_BRANCH defaults to main (env.sh may have set it to 'master')
|
||||
PRIMARY_BRANCH="${PRIMARY_BRANCH:-main}"
|
||||
|
||||
# Generate docker-compose.yml in the factory root.
|
||||
_generate_compose_impl() {
|
||||
local forge_port="${1:-3000}"
|
||||
local compose_file="${FACTORY_ROOT}/docker-compose.yml"
|
||||
|
||||
# Check if compose file already exists
|
||||
if [ -f "$compose_file" ]; then
|
||||
echo "Compose: ${compose_file} (already exists, skipping)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
cat > "$compose_file" <<'COMPOSEEOF'
|
||||
# docker-compose.yml — generated by disinto init
|
||||
# Brings up Forgejo, Woodpecker, and the agent runtime.
|
||||
|
||||
services:
|
||||
forgejo:
|
||||
image: codeberg.org/forgejo/forgejo:1
|
||||
container_name: disinto-forgejo
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- apparmor=unconfined
|
||||
volumes:
|
||||
- forgejo-data:/data
|
||||
environment:
|
||||
FORGEJO__database__DB_TYPE: sqlite3
|
||||
FORGEJO__server__ROOT_URL: http://forgejo:3000/
|
||||
FORGEJO__server__HTTP_PORT: "3000"
|
||||
FORGEJO__security__INSTALL_LOCK: "true"
|
||||
FORGEJO__service__DISABLE_REGISTRATION: "true"
|
||||
FORGEJO__webhook__ALLOWED_HOST_LIST: "private"
|
||||
networks:
|
||||
- disinto-net
|
||||
|
||||
woodpecker:
|
||||
image: woodpeckerci/woodpecker-server:v3
|
||||
container_name: disinto-woodpecker
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- apparmor=unconfined
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "9000:9000"
|
||||
volumes:
|
||||
- woodpecker-data:/var/lib/woodpecker
|
||||
environment:
|
||||
WOODPECKER_FORGEJO: "true"
|
||||
WOODPECKER_FORGEJO_URL: http://forgejo:3000
|
||||
WOODPECKER_FORGEJO_CLIENT: ${WP_FORGEJO_CLIENT:-}
|
||||
WOODPECKER_FORGEJO_SECRET: ${WP_FORGEJO_SECRET:-}
|
||||
WOODPECKER_HOST: ${WOODPECKER_HOST:-http://woodpecker:8000}
|
||||
WOODPECKER_OPEN: "true"
|
||||
WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET:-}
|
||||
WOODPECKER_DATABASE_DRIVER: sqlite3
|
||||
WOODPECKER_DATABASE_DATASOURCE: /var/lib/woodpecker/woodpecker.sqlite
|
||||
WOODPECKER_ENVIRONMENT: "FORGE_TOKEN:${FORGE_TOKEN}"
|
||||
depends_on:
|
||||
- forgejo
|
||||
networks:
|
||||
- disinto-net
|
||||
|
||||
woodpecker-agent:
|
||||
image: woodpeckerci/woodpecker-agent:v3
|
||||
container_name: disinto-woodpecker-agent
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
privileged: true
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
WOODPECKER_SERVER: localhost:9000
|
||||
WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET:-}
|
||||
WOODPECKER_GRPC_SECURE: "false"
|
||||
WOODPECKER_HEALTHCHECK_ADDR: ":3333"
|
||||
WOODPECKER_BACKEND_DOCKER_NETWORK: disinto_disinto-net
|
||||
WOODPECKER_MAX_WORKFLOWS: 1
|
||||
depends_on:
|
||||
- woodpecker
|
||||
|
||||
agents:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/agents/Dockerfile
|
||||
container_name: disinto-agents
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- apparmor=unconfined
|
||||
volumes:
|
||||
- agent-data:/home/agent/data
|
||||
- project-repos:/home/agent/repos
|
||||
- ${HOME}/.claude:/home/agent/.claude
|
||||
- ${HOME}/.claude.json:/home/agent/.claude.json:ro
|
||||
- CLAUDE_BIN_PLACEHOLDER:/usr/local/bin/claude:ro
|
||||
- ${HOME}/.ssh:/home/agent/.ssh:ro
|
||||
- ${HOME}/.config/sops/age:/home/agent/.config/sops/age:ro
|
||||
- woodpecker-data:/woodpecker-data:ro
|
||||
environment:
|
||||
FORGE_URL: http://forgejo:3000
|
||||
WOODPECKER_SERVER: http://woodpecker:8000
|
||||
DISINTO_CONTAINER: "1"
|
||||
PROJECT_REPO_ROOT: /home/agent/repos/${PROJECT_NAME:-project}
|
||||
WOODPECKER_DATA_DIR: /woodpecker-data
|
||||
env_file:
|
||||
- .env
|
||||
# IMPORTANT: agents get .env only (forge tokens, CI tokens, config).
|
||||
# Vault-only secrets (GITHUB_TOKEN, CLAWHUB_TOKEN, deploy keys) live in
|
||||
# .env.vault.enc and are NEVER injected here — only the runner
|
||||
# container receives them at fire time (AD-006, #745).
|
||||
depends_on:
|
||||
- forgejo
|
||||
- woodpecker
|
||||
networks:
|
||||
- disinto-net
|
||||
|
||||
runner:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/agents/Dockerfile
|
||||
profiles: ["vault"]
|
||||
security_opt:
|
||||
- apparmor=unconfined
|
||||
volumes:
|
||||
- agent-data:/home/agent/data
|
||||
environment:
|
||||
FORGE_URL: http://forgejo:3000
|
||||
DISINTO_CONTAINER: "1"
|
||||
PROJECT_REPO_ROOT: /home/agent/repos/${PROJECT_NAME:-project}
|
||||
# Vault redesign in progress (PR-based approval, see #73-#77)
|
||||
# This container is being replaced — entrypoint will be updated in follow-up
|
||||
networks:
|
||||
- disinto-net
|
||||
|
||||
# Edge proxy — reverse proxy to Forgejo, Woodpecker, and staging
|
||||
# Serves on ports 80/443, routes based on path
|
||||
edge:
|
||||
build: ./docker/edge
|
||||
container_name: disinto-edge
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
environment:
|
||||
- DISINTO_VERSION=${DISINTO_VERSION:-main}
|
||||
- FORGE_URL=http://forgejo:3000
|
||||
- FORGE_REPO=${FORGE_REPO:-disinto-admin/disinto}
|
||||
- FORGE_OPS_REPO=${FORGE_OPS_REPO:-disinto-admin/disinto-ops}
|
||||
- FORGE_TOKEN=${FORGE_TOKEN:-}
|
||||
- FORGE_ADMIN_USERS=${FORGE_ADMIN_USERS:-disinto-admin}
|
||||
- FORGE_ADMIN_TOKEN=${FORGE_ADMIN_TOKEN:-}
|
||||
- OPS_REPO_ROOT=/opt/disinto-ops
|
||||
- PROJECT_REPO_ROOT=/opt/disinto
|
||||
- PRIMARY_BRANCH=main
|
||||
volumes:
|
||||
- ./docker/Caddyfile:/etc/caddy/Caddyfile
|
||||
- caddy_data:/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
depends_on:
|
||||
- forgejo
|
||||
- woodpecker
|
||||
- staging
|
||||
networks:
|
||||
- disinto-net
|
||||
|
||||
# Staging container — static file server for staging artifacts
|
||||
# Edge proxy routes to this container for default requests
|
||||
staging:
|
||||
image: caddy:alpine
|
||||
command: ["caddy", "file-server", "--root", "/srv/site"]
|
||||
volumes:
|
||||
- ./docker:/srv/site:ro
|
||||
networks:
|
||||
- disinto-net
|
||||
|
||||
# Staging deployment slot — activated by Woodpecker staging pipeline (#755).
|
||||
# Profile-gated: only starts when explicitly targeted by deploy commands.
|
||||
# Customize image/ports/volumes for your project after init.
|
||||
staging-deploy:
|
||||
image: alpine:3
|
||||
profiles: ["staging"]
|
||||
security_opt:
|
||||
- apparmor=unconfined
|
||||
environment:
|
||||
DEPLOY_ENV: staging
|
||||
networks:
|
||||
- disinto-net
|
||||
command: ["echo", "staging slot — replace with project image"]
|
||||
|
||||
volumes:
|
||||
forgejo-data:
|
||||
woodpecker-data:
|
||||
agent-data:
|
||||
project-repos:
|
||||
caddy_data:
|
||||
|
||||
networks:
|
||||
disinto-net:
|
||||
driver: bridge
|
||||
COMPOSEEOF
|
||||
|
||||
# Patch the Claude CLI binary path — resolve from host PATH at init time.
|
||||
local claude_bin
|
||||
claude_bin="$(command -v claude 2>/dev/null || true)"
|
||||
if [ -n "$claude_bin" ]; then
|
||||
# Resolve symlinks to get the real binary path
|
||||
claude_bin="$(readlink -f "$claude_bin")"
|
||||
sed -i "s|CLAUDE_BIN_PLACEHOLDER|${claude_bin}|" "$compose_file"
|
||||
else
|
||||
echo "Warning: claude CLI not found in PATH — update docker-compose.yml volumes manually" >&2
|
||||
sed -i "s|CLAUDE_BIN_PLACEHOLDER|/usr/local/bin/claude|" "$compose_file"
|
||||
fi
|
||||
|
||||
# Patch the forgejo port mapping into the file if non-default
|
||||
if [ "$forge_port" != "3000" ]; then
|
||||
# Add port mapping to forgejo service so it's reachable from host during init
|
||||
sed -i "/image: codeberg\.org\/forgejo\/forgejo:1/a\\ ports:\\n - \"${forge_port}:3000\"" "$compose_file"
|
||||
else
|
||||
sed -i "/image: codeberg\.org\/forgejo\/forgejo:1/a\\ ports:\\n - \"3000:3000\"" "$compose_file"
|
||||
fi
|
||||
|
||||
echo "Created: ${compose_file}"
|
||||
}
|
||||
|
||||
# Generate docker/agents/ files if they don't already exist.
|
||||
_generate_agent_docker_impl() {
|
||||
local docker_dir="${FACTORY_ROOT}/docker/agents"
|
||||
mkdir -p "$docker_dir"
|
||||
|
||||
if [ ! -f "${docker_dir}/Dockerfile" ]; then
|
||||
echo "Warning: docker/agents/Dockerfile not found — expected in repo" >&2
|
||||
fi
|
||||
if [ ! -f "${docker_dir}/entrypoint.sh" ]; then
|
||||
echo "Warning: docker/agents/entrypoint.sh not found — expected in repo" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# Generate docker/Caddyfile template for edge proxy.
|
||||
_generate_caddyfile_impl() {
|
||||
local docker_dir="${FACTORY_ROOT}/docker"
|
||||
local caddyfile="${docker_dir}/Caddyfile"
|
||||
|
||||
if [ -f "$caddyfile" ]; then
|
||||
echo "Caddyfile: ${caddyfile} (already exists, skipping)"
|
||||
return
|
||||
fi
|
||||
|
||||
cat > "$caddyfile" <<'CADDYFILEEOF'
|
||||
# Caddyfile — edge proxy configuration
|
||||
# IP-only binding at bootstrap; domain + TLS added later via vault resource request
|
||||
|
||||
:80 {
|
||||
# Reverse proxy to Forgejo
|
||||
handle /forgejo/* {
|
||||
reverse_proxy forgejo:3000
|
||||
}
|
||||
|
||||
# Reverse proxy to Woodpecker CI
|
||||
handle /ci/* {
|
||||
reverse_proxy woodpecker:8000
|
||||
}
|
||||
|
||||
# Default: proxy to staging container
|
||||
handle {
|
||||
reverse_proxy staging:80
|
||||
}
|
||||
}
|
||||
CADDYFILEEOF
|
||||
|
||||
echo "Created: ${caddyfile}"
|
||||
}
|
||||
|
||||
# Generate docker/index.html default page.
|
||||
_generate_staging_index_impl() {
|
||||
local docker_dir="${FACTORY_ROOT}/docker"
|
||||
local index_file="${docker_dir}/index.html"
|
||||
|
||||
if [ -f "$index_file" ]; then
|
||||
echo "Staging: ${index_file} (already exists, skipping)"
|
||||
return
|
||||
fi
|
||||
|
||||
cat > "$index_file" <<'INDEXEOF'
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nothing shipped yet</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
p {
|
||||
font-size: 1.25rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Nothing shipped yet</h1>
|
||||
<p>CI pipelines will update this page with your staging artifacts.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
INDEXEOF
|
||||
|
||||
echo "Created: ${index_file}"
|
||||
}
|
||||
|
||||
# Generate template .woodpecker/ deployment pipeline configs in a project repo.
|
||||
# Creates staging.yml and production.yml alongside the project's existing CI config.
|
||||
# These pipelines trigger on Woodpecker's deployment event with environment filters.
|
||||
_generate_deploy_pipelines_impl() {
|
||||
local repo_root="$1"
|
||||
local project_name="$2"
|
||||
: "${project_name// /}" # Silence SC2034 - variable used in heredoc
|
||||
local wp_dir="${repo_root}/.woodpecker"
|
||||
|
||||
mkdir -p "$wp_dir"
|
||||
|
||||
# Skip if deploy pipelines already exist
|
||||
if [ -f "${wp_dir}/staging.yml" ] && [ -f "${wp_dir}/production.yml" ]; then
|
||||
echo "Deploy: .woodpecker/{staging,production}.yml (already exist)"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ ! -f "${wp_dir}/staging.yml" ]; then
|
||||
cat > "${wp_dir}/staging.yml" <<'STAGINGEOF'
|
||||
# .woodpecker/staging.yml — Staging deployment pipeline
|
||||
# Triggered by runner via Woodpecker promote API.
|
||||
# Human approves promotion in vault → runner calls promote → this runs.
|
||||
|
||||
when:
|
||||
event: deployment
|
||||
environment: staging
|
||||
|
||||
steps:
|
||||
- name: deploy-staging
|
||||
image: docker:27
|
||||
commands:
|
||||
- echo "Deploying to staging environment..."
|
||||
- echo "Pipeline ${CI_PIPELINE_NUMBER} promoted from CI #${CI_PIPELINE_PARENT}"
|
||||
# Pull the image built by CI and deploy to staging
|
||||
# Customize these commands for your project:
|
||||
# - docker compose -f docker-compose.yml --profile staging up -d
|
||||
- echo "Staging deployment complete"
|
||||
|
||||
- name: verify-staging
|
||||
image: alpine:3
|
||||
commands:
|
||||
- echo "Verifying staging deployment..."
|
||||
# Add health checks, smoke tests, or integration tests here:
|
||||
# - curl -sf http://staging:8080/health || exit 1
|
||||
- echo "Staging verification complete"
|
||||
STAGINGEOF
|
||||
echo "Created: ${wp_dir}/staging.yml"
|
||||
fi
|
||||
|
||||
if [ ! -f "${wp_dir}/production.yml" ]; then
|
||||
cat > "${wp_dir}/production.yml" <<'PRODUCTIONEOF'
|
||||
# .woodpecker/production.yml — Production deployment pipeline
|
||||
# Triggered by runner via Woodpecker promote API.
|
||||
# Human approves promotion in vault → runner calls promote → this runs.
|
||||
|
||||
when:
|
||||
event: deployment
|
||||
environment: production
|
||||
|
||||
steps:
|
||||
- name: deploy-production
|
||||
image: docker:27
|
||||
commands:
|
||||
- echo "Deploying to production environment..."
|
||||
- echo "Pipeline ${CI_PIPELINE_NUMBER} promoted from staging"
|
||||
# Pull the verified image and deploy to production
|
||||
# Customize these commands for your project:
|
||||
# - docker compose -f docker-compose.yml up -d
|
||||
- echo "Production deployment complete"
|
||||
|
||||
- name: verify-production
|
||||
image: alpine:3
|
||||
commands:
|
||||
- echo "Verifying production deployment..."
|
||||
# Add production health checks here:
|
||||
# - curl -sf http://production:8080/health || exit 1
|
||||
- echo "Production verification complete"
|
||||
PRODUCTIONEOF
|
||||
echo "Created: ${wp_dir}/production.yml"
|
||||
fi
|
||||
}
|
||||
|
|
@ -1,464 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# hire-agent — disinto_hire_an_agent() function
|
||||
#
|
||||
# Handles user creation, .profile repo setup, formula copying, branch protection,
|
||||
# and state marker creation for hiring a new agent.
|
||||
#
|
||||
# Globals expected:
|
||||
# FORGE_URL - Forge instance URL
|
||||
# FORGE_TOKEN - Admin token for Forge operations
|
||||
# FACTORY_ROOT - Root of the disinto factory
|
||||
# PROJECT_NAME - Project name for email/domain generation
|
||||
#
|
||||
# Usage:
|
||||
# source "${FACTORY_ROOT}/lib/hire-agent.sh"
|
||||
# disinto_hire_an_agent <agent-name> <role> [--formula <path>] [--local-model <url>] [--poll-interval <seconds>]
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
disinto_hire_an_agent() {
|
||||
local agent_name="${1:-}"
|
||||
local role="${2:-}"
|
||||
local formula_path=""
|
||||
local local_model=""
|
||||
local poll_interval=""
|
||||
|
||||
if [ -z "$agent_name" ] || [ -z "$role" ]; then
|
||||
echo "Error: agent-name and role required" >&2
|
||||
echo "Usage: disinto hire-an-agent <agent-name> <role> [--formula <path>] [--local-model <url>] [--poll-interval <seconds>]" >&2
|
||||
exit 1
|
||||
fi
|
||||
shift 2
|
||||
|
||||
# Parse flags
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--formula)
|
||||
formula_path="$2"
|
||||
shift 2
|
||||
;;
|
||||
--local-model)
|
||||
local_model="$2"
|
||||
shift 2
|
||||
;;
|
||||
--poll-interval)
|
||||
poll_interval="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Default formula path — try both naming conventions
|
||||
if [ -z "$formula_path" ]; then
|
||||
formula_path="${FACTORY_ROOT}/formulas/${role}.toml"
|
||||
if [ ! -f "$formula_path" ]; then
|
||||
formula_path="${FACTORY_ROOT}/formulas/run-${role}.toml"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Validate formula exists
|
||||
if [ ! -f "$formula_path" ]; then
|
||||
echo "Error: formula not found at ${formula_path}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "── Hiring agent: ${agent_name} (${role}) ───────────────────────"
|
||||
echo "Formula: ${formula_path}"
|
||||
if [ -n "$local_model" ]; then
|
||||
echo "Local model: ${local_model}"
|
||||
echo "Poll interval: ${poll_interval:-300}s"
|
||||
fi
|
||||
|
||||
# Ensure FORGE_TOKEN is set
|
||||
if [ -z "${FORGE_TOKEN:-}" ]; then
|
||||
echo "Error: FORGE_TOKEN not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get Forge URL
|
||||
local forge_url="${FORGE_URL:-http://localhost:3000}"
|
||||
echo "Forge: ${forge_url}"
|
||||
|
||||
# Step 1: Create user via API (skip if exists)
|
||||
echo ""
|
||||
echo "Step 1: Creating user '${agent_name}' (if not exists)..."
|
||||
|
||||
local user_pass=""
|
||||
local admin_pass=""
|
||||
|
||||
# Read admin password from .env for standalone runs (#184)
|
||||
local env_file="${FACTORY_ROOT}/.env"
|
||||
if [ -f "$env_file" ] && grep -q '^FORGE_ADMIN_PASS=' "$env_file" 2>/dev/null; then
|
||||
admin_pass=$(grep '^FORGE_ADMIN_PASS=' "$env_file" | head -1 | cut -d= -f2-)
|
||||
fi
|
||||
|
||||
# Get admin token early (needed for both user creation and password reset)
|
||||
local admin_user="disinto-admin"
|
||||
admin_pass="${admin_pass:-admin}"
|
||||
local admin_token=""
|
||||
local admin_token_name
|
||||
admin_token_name="temp-token-$(date +%s)"
|
||||
admin_token=$(curl -sf -X POST \
|
||||
-u "${admin_user}:${admin_pass}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/users/${admin_user}/tokens" \
|
||||
-d "{\"name\":\"${admin_token_name}\",\"scopes\":[\"all\"]}" 2>/dev/null \
|
||||
| jq -r '.sha1 // empty') || admin_token=""
|
||||
if [ -z "$admin_token" ]; then
|
||||
# Token might already exist — try listing
|
||||
admin_token=$(curl -sf \
|
||||
-u "${admin_user}:${admin_pass}" \
|
||||
"${forge_url}/api/v1/users/${admin_user}/tokens" 2>/dev/null \
|
||||
| jq -r '.[0].sha1 // empty') || admin_token=""
|
||||
fi
|
||||
if [ -z "$admin_token" ]; then
|
||||
echo "Error: failed to obtain admin API token" >&2
|
||||
echo " Cannot proceed without admin privileges" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if curl -sf --max-time 5 "${forge_url}/api/v1/users/${agent_name}" >/dev/null 2>&1; then
|
||||
echo " User '${agent_name}' already exists"
|
||||
# Reset user password so we can get a token (#184)
|
||||
user_pass="agent-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)"
|
||||
# Use Forgejo CLI to reset password (API PATCH ignores must_change_password in Forgejo 11.x)
|
||||
if _forgejo_exec forgejo admin user change-password \
|
||||
--username "${agent_name}" \
|
||||
--password "${user_pass}" \
|
||||
--must-change-password=false >/dev/null 2>&1; then
|
||||
echo " Reset password for existing user '${agent_name}'"
|
||||
else
|
||||
echo " Warning: could not reset password for existing user" >&2
|
||||
fi
|
||||
else
|
||||
# Create user using basic auth (admin token fallback would poison subsequent calls)
|
||||
# Create the user
|
||||
user_pass="agent-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)"
|
||||
if curl -sf -X POST \
|
||||
-u "${admin_user}:${admin_pass}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/admin/users" \
|
||||
-d "{\"username\":\"${agent_name}\",\"password\":\"${user_pass}\",\"email\":\"${agent_name}@${PROJECT_NAME:-disinto}.local\",\"full_name\":\"${agent_name}\",\"active\":true,\"admin\":false,\"must_change_password\":false}" >/dev/null 2>&1; then
|
||||
echo " Created user '${agent_name}'"
|
||||
else
|
||||
echo " Warning: failed to create user via admin API" >&2
|
||||
# Try alternative: user might already exist
|
||||
if curl -sf --max-time 5 "${forge_url}/api/v1/users/${agent_name}" >/dev/null 2>&1; then
|
||||
echo " User '${agent_name}' exists (confirmed)"
|
||||
else
|
||||
echo " Error: failed to create user '${agent_name}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 1.5: Generate Forge token for the new/existing user
|
||||
echo ""
|
||||
echo "Step 1.5: Generating Forge token for '${agent_name}'..."
|
||||
|
||||
# Convert role to uppercase token variable name (e.g., architect -> FORGE_ARCHITECT_TOKEN)
|
||||
local role_upper
|
||||
role_upper=$(echo "$role" | tr '[:lower:]' '[:upper:]')
|
||||
local token_var="FORGE_${role_upper}_TOKEN"
|
||||
|
||||
# Generate token using the user's password (basic auth)
|
||||
local agent_token=""
|
||||
agent_token=$(curl -sf -X POST \
|
||||
-u "${agent_name}:${user_pass}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/users/${agent_name}/tokens" \
|
||||
-d "{\"name\":\"disinto-${agent_name}-token\",\"scopes\":[\"all\"]}" 2>/dev/null \
|
||||
| jq -r '.sha1 // empty') || agent_token=""
|
||||
|
||||
if [ -z "$agent_token" ]; then
|
||||
# Token name collision — create with timestamp suffix
|
||||
agent_token=$(curl -sf -X POST \
|
||||
-u "${agent_name}:${user_pass}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/users/${agent_name}/tokens" \
|
||||
-d "{\"name\":\"disinto-${agent_name}-$(date +%s)\",\"scopes\":[\"all\"]}" 2>/dev/null \
|
||||
| jq -r '.sha1 // empty') || agent_token=""
|
||||
fi
|
||||
|
||||
if [ -z "$agent_token" ]; then
|
||||
echo " Warning: failed to create API token for '${agent_name}'" >&2
|
||||
else
|
||||
# Store token in .env under the role-specific variable name
|
||||
if grep -q "^${token_var}=" "$env_file" 2>/dev/null; then
|
||||
# Use sed with alternative delimiter and proper escaping for special chars in token
|
||||
local escaped_token
|
||||
escaped_token=$(printf '%s\n' "$agent_token" | sed 's/[&/\]/\\&/g')
|
||||
sed -i "s|^${token_var}=.*|${token_var}=${escaped_token}|" "$env_file"
|
||||
echo " ${agent_name} token updated (${token_var})"
|
||||
else
|
||||
printf '%s=%s\n' "$token_var" "$agent_token" >> "$env_file"
|
||||
echo " ${agent_name} token saved (${token_var})"
|
||||
fi
|
||||
export "${token_var}=${agent_token}"
|
||||
fi
|
||||
|
||||
# Step 2: Create .profile repo on Forgejo
|
||||
echo ""
|
||||
echo "Step 2: Creating '${agent_name}/.profile' repo (if not exists)..."
|
||||
|
||||
if curl -sf --max-time 5 "${forge_url}/api/v1/repos/${agent_name}/.profile" >/dev/null 2>&1; then
|
||||
echo " Repo '${agent_name}/.profile' already exists"
|
||||
else
|
||||
# Create the repo using the admin API to ensure it's created in the agent's namespace.
|
||||
# Using POST /api/v1/user/repos with a user token would create the repo under the
|
||||
# authenticated user, which could be wrong if the token belongs to a different user.
|
||||
# The admin API POST /api/v1/admin/users/{username}/repos explicitly creates in the
|
||||
# specified user's namespace.
|
||||
local create_output
|
||||
create_output=$(curl -sf -X POST \
|
||||
-u "${admin_user}:${admin_pass}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/admin/users/${agent_name}/repos" \
|
||||
-d "{\"name\":\".profile\",\"description\":\"${agent_name}'s .profile repo\",\"private\":true,\"auto_init\":false}" 2>&1) || true
|
||||
|
||||
if echo "$create_output" | grep -q '"id":\|[0-9]'; then
|
||||
echo " Created repo '${agent_name}/.profile' (via admin API)"
|
||||
else
|
||||
echo " Error: failed to create repo '${agent_name}/.profile'" >&2
|
||||
echo " Response: ${create_output}" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 3: Clone repo and create initial commit
|
||||
echo ""
|
||||
echo "Step 3: Cloning repo and creating initial commit..."
|
||||
|
||||
local clone_dir="/tmp/.profile-clone-${agent_name}"
|
||||
rm -rf "$clone_dir"
|
||||
mkdir -p "$clone_dir"
|
||||
|
||||
# Build authenticated clone URL using basic auth (user_pass is always set in Step 1)
|
||||
if [ -z "${user_pass:-}" ]; then
|
||||
echo " Error: no user password available for cloning" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local auth_url
|
||||
auth_url=$(printf '%s' "$forge_url" | sed "s|://|://${agent_name}:${user_pass}@|")
|
||||
auth_url="${auth_url}/${agent_name}/.profile.git"
|
||||
|
||||
# Display unauthenticated URL (auth token only in actual git clone command)
|
||||
echo " Cloning: ${forge_url}/${agent_name}/.profile.git"
|
||||
|
||||
# Try authenticated clone first (required for private repos)
|
||||
if ! git clone --quiet "$auth_url" "$clone_dir" 2>/dev/null; then
|
||||
echo " Error: failed to clone repo with authentication" >&2
|
||||
echo " Note: Ensure the user has a valid API token with repository access" >&2
|
||||
rm -rf "$clone_dir"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Configure git
|
||||
git -C "$clone_dir" config user.name "disinto-admin"
|
||||
git -C "$clone_dir" config user.email "disinto-admin@localhost"
|
||||
|
||||
# Create directory structure
|
||||
echo " Creating directory structure..."
|
||||
mkdir -p "${clone_dir}/journal"
|
||||
mkdir -p "${clone_dir}/knowledge"
|
||||
touch "${clone_dir}/journal/.gitkeep"
|
||||
touch "${clone_dir}/knowledge/.gitkeep"
|
||||
|
||||
# Copy formula
|
||||
echo " Copying formula..."
|
||||
cp "$formula_path" "${clone_dir}/formula.toml"
|
||||
|
||||
# Create README
|
||||
if [ ! -f "${clone_dir}/README.md" ]; then
|
||||
cat > "${clone_dir}/README.md" <<EOF
|
||||
# ${agent_name}'s .profile
|
||||
|
||||
Agent profile repository for ${agent_name}.
|
||||
|
||||
## Structure
|
||||
|
||||
\`\`\`
|
||||
${agent_name}/.profile/
|
||||
├── formula.toml # Agent's role formula
|
||||
├── journal/ # Issue-by-issue log files (journal branch)
|
||||
│ └── .gitkeep
|
||||
├── knowledge/ # Shared knowledge and best practices
|
||||
│ └── .gitkeep
|
||||
└── README.md
|
||||
\`\`\`
|
||||
|
||||
## Branches
|
||||
|
||||
- \`main\` — Admin-only merge for formula changes (requires 1 approval)
|
||||
- \`journal\` — Agent branch for direct journal entries
|
||||
- Agent can push directly to this branch
|
||||
- Formula changes must go through PR to \`main\`
|
||||
|
||||
## Branch protection
|
||||
|
||||
- \`main\`: Protected — requires 1 admin approval for merges
|
||||
- \`journal\`: Unprotected — agent can push directly
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Commit and push
|
||||
echo " Committing and pushing..."
|
||||
git -C "$clone_dir" add -A
|
||||
if ! git -C "$clone_dir" diff --cached --quiet 2>/dev/null; then
|
||||
git -C "$clone_dir" commit -m "chore: initial .profile setup" -q
|
||||
git -C "$clone_dir" push origin main >/dev/null 2>&1 || \
|
||||
git -C "$clone_dir" push origin master >/dev/null 2>&1 || true
|
||||
echo " Committed: initial .profile setup"
|
||||
else
|
||||
echo " No changes to commit"
|
||||
fi
|
||||
|
||||
rm -rf "$clone_dir"
|
||||
|
||||
# Step 4: Set up branch protection
|
||||
echo ""
|
||||
echo "Step 4: Setting up branch protection..."
|
||||
|
||||
# Source branch-protection.sh helper
|
||||
local bp_script="${FACTORY_ROOT}/lib/branch-protection.sh"
|
||||
if [ -f "$bp_script" ]; then
|
||||
# Source required environment
|
||||
if [ -f "${FACTORY_ROOT}/lib/env.sh" ]; then
|
||||
source "${FACTORY_ROOT}/lib/env.sh"
|
||||
fi
|
||||
|
||||
# Set up branch protection for .profile repo
|
||||
if source "$bp_script" 2>/dev/null && setup_profile_branch_protection "${agent_name}/.profile" "main"; then
|
||||
echo " Branch protection configured for main branch"
|
||||
echo " - Requires 1 approval before merge"
|
||||
echo " - Admin-only merge enforcement"
|
||||
echo " - Journal branch created for direct agent pushes"
|
||||
else
|
||||
echo " Warning: could not configure branch protection (Forgejo API may not be available)"
|
||||
echo " Note: Branch protection can be set up manually later"
|
||||
fi
|
||||
else
|
||||
echo " Warning: branch-protection.sh not found at ${bp_script}"
|
||||
fi
|
||||
|
||||
# Step 5: Create state marker
|
||||
echo ""
|
||||
echo "Step 5: Creating state marker..."
|
||||
|
||||
local state_dir="${FACTORY_ROOT}/state"
|
||||
mkdir -p "$state_dir"
|
||||
local state_file="${state_dir}/.${role}-active"
|
||||
|
||||
if [ ! -f "$state_file" ]; then
|
||||
touch "$state_file"
|
||||
echo " Created: ${state_file}"
|
||||
else
|
||||
echo " State marker already exists: ${state_file}"
|
||||
fi
|
||||
|
||||
# Step 6: Set up local model agent (if --local-model specified)
|
||||
if [ -n "$local_model" ]; then
|
||||
echo ""
|
||||
echo "Step 6: Configuring local model agent..."
|
||||
|
||||
local override_file="${FACTORY_ROOT}/docker-compose.override.yml"
|
||||
local override_dir
|
||||
override_dir=$(dirname "$override_file")
|
||||
mkdir -p "$override_dir"
|
||||
|
||||
# Validate model endpoint is reachable
|
||||
echo " Validating model endpoint: ${local_model}"
|
||||
if ! curl -sf --max-time 10 "${local_model}/health" >/dev/null 2>&1; then
|
||||
# Try /v1/chat/completions as fallback endpoint check
|
||||
if ! curl -sf --max-time 10 "${local_model}/v1/chat/completions" >/dev/null 2>&1; then
|
||||
echo " Warning: model endpoint may not be reachable at ${local_model}"
|
||||
echo " Continuing with configuration..."
|
||||
fi
|
||||
else
|
||||
echo " Model endpoint is reachable"
|
||||
fi
|
||||
|
||||
# Generate service name from agent name (lowercase)
|
||||
local service_name="agents-${agent_name}"
|
||||
service_name=$(echo "$service_name" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
# Set default poll interval
|
||||
local interval="${poll_interval:-300}"
|
||||
|
||||
# Generate the override compose file
|
||||
# Bash expands ${service_name}, ${local_model}, ${interval}, ${PROJECT_NAME} at generation time
|
||||
# \$HOME, \$FORGE_TOKEN become ${HOME}, ${FORGE_TOKEN} in the file for docker-compose runtime expansion
|
||||
cat > "$override_file" <<OVERRIDEOF
|
||||
# docker-compose.override.yml — auto-generated by disinto hire-an-agent
|
||||
# Local model agent configuration for ${agent_name}
|
||||
|
||||
services:
|
||||
${service_name}:
|
||||
image: disinto-agents:latest
|
||||
profiles: ["local-model"]
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- apparmor=unconfined
|
||||
volumes:
|
||||
- agent-data-llama:/home/agent/data
|
||||
- project-repos-llama:/home/agent/repos
|
||||
- \$HOME/.claude:/home/agent/.claude
|
||||
- \$HOME/.claude.json:/home/agent/.claude.json:ro
|
||||
- CLAUDE_BIN_PLACEHOLDER:/usr/local/bin/claude:ro
|
||||
- \$HOME/.ssh:/home/agent/.ssh:ro
|
||||
- \$HOME/.config/sops/age:/home/agent/.config/sops/age:ro
|
||||
environment:
|
||||
FORGE_URL: http://forgejo:3000
|
||||
WOODPECKER_SERVER: http://woodpecker:8000
|
||||
DISINTO_CONTAINER: "1"
|
||||
PROJECT_REPO_ROOT: /home/agent/repos/${PROJECT_NAME:-project}
|
||||
WOODPECKER_DATA_DIR: /woodpecker-data
|
||||
ANTHROPIC_BASE_URL: ${local_model}
|
||||
ANTHROPIC_API_KEY: sk-no-key-required
|
||||
FORGE_TOKEN_OVERRIDE: \$FORGE_TOKEN
|
||||
CLAUDE_CONFIG_DIR: /home/agent/.claude
|
||||
POLL_INTERVAL: ${interval}
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- forgejo
|
||||
- woodpecker
|
||||
entrypoint: ["/home/agent/entrypoint-llama.sh"]
|
||||
|
||||
volumes:
|
||||
agent-data-llama:
|
||||
project-repos-llama:
|
||||
OVERRIDEOF
|
||||
|
||||
# Patch the Claude CLI binary path
|
||||
local claude_bin
|
||||
claude_bin="$(command -v claude 2>/dev/null || true)"
|
||||
if [ -n "$claude_bin" ]; then
|
||||
claude_bin="$(readlink -f "$claude_bin")"
|
||||
sed -i "s|CLAUDE_BIN_PLACEHOLDER|${claude_bin}|" "$override_file"
|
||||
else
|
||||
echo " Warning: claude CLI not found — update override file manually"
|
||||
sed -i "s|CLAUDE_BIN_PLACEHOLDER|/usr/local/bin/claude|" "$override_file"
|
||||
fi
|
||||
|
||||
echo " Created: ${override_file}"
|
||||
echo " Service name: ${service_name}"
|
||||
echo " Poll interval: ${interval}s"
|
||||
echo " Model endpoint: ${local_model}"
|
||||
echo ""
|
||||
echo " To start the agent, run:"
|
||||
echo " docker compose --profile local-model up -d ${service_name}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done! Agent '${agent_name}' hired for role '${role}'."
|
||||
echo " User: ${forge_url}/${agent_name}"
|
||||
echo " Repo: ${forge_url}/${agent_name}/.profile"
|
||||
echo " Formula: ${role}.toml"
|
||||
}
|
||||
|
|
@ -1,428 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# issue-lifecycle.sh — Reusable issue lifecycle library for agents
|
||||
#
|
||||
# Source after lib/env.sh:
|
||||
# source "$FACTORY_ROOT/lib/issue-lifecycle.sh"
|
||||
#
|
||||
# Required globals: FORGE_TOKEN, FORGE_API, FACTORY_ROOT
|
||||
#
|
||||
# Functions:
|
||||
# issue_claim ISSUE_NUMBER
|
||||
# issue_release ISSUE_NUMBER
|
||||
# issue_block ISSUE_NUMBER REASON [RESULT_TEXT]
|
||||
# issue_close ISSUE_NUMBER
|
||||
# issue_check_deps ISSUE_NUMBER
|
||||
# issue_suggest_next
|
||||
# issue_post_refusal ISSUE_NUMBER EMOJI TITLE BODY
|
||||
#
|
||||
# Output variables (set by issue_check_deps):
|
||||
# _ISSUE_BLOCKED_BY array of blocking issue numbers
|
||||
# _ISSUE_SUGGESTION suggested next issue number (or empty)
|
||||
#
|
||||
# Output variables (set by issue_suggest_next):
|
||||
# _ISSUE_NEXT next unblocked backlog issue number (or empty)
|
||||
#
|
||||
# shellcheck shell=bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Source secret scanner for redacting text before posting to issues
|
||||
# shellcheck source=secret-scan.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/secret-scan.sh"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal log helper
|
||||
# ---------------------------------------------------------------------------
|
||||
_ilc_log() {
|
||||
if declare -f log >/dev/null 2>&1; then
|
||||
log "issue-lifecycle: $*"
|
||||
else
|
||||
printf '[%s] issue-lifecycle: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Label ID caching — lookup once per name, cache in globals.
|
||||
# ---------------------------------------------------------------------------
|
||||
declare -A _ILC_LABEL_IDS
|
||||
_ILC_LABEL_IDS["backlog"]=""
|
||||
_ILC_LABEL_IDS["in-progress"]=""
|
||||
_ILC_LABEL_IDS["blocked"]=""
|
||||
|
||||
# _ilc_ensure_label_id LABEL_NAME [COLOR]
|
||||
# Looks up label by name, creates if missing, caches in associative array.
|
||||
_ilc_ensure_label_id() {
|
||||
local name="$1" color="${2:-#e0e0e0}"
|
||||
local current="${_ILC_LABEL_IDS[$name]:-}"
|
||||
if [ -n "$current" ]; then
|
||||
printf '%s' "$current"
|
||||
return 0
|
||||
fi
|
||||
local label_id
|
||||
label_id=$(forge_api GET "/labels" 2>/dev/null \
|
||||
| jq -r --arg n "$name" '.[] | select(.name == $n) | .id' 2>/dev/null || true)
|
||||
if [ -z "$label_id" ]; then
|
||||
label_id=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/labels" \
|
||||
-d "$(jq -nc --arg n "$name" --arg c "$color" '{name:$n,color:$c}')" 2>/dev/null \
|
||||
| jq -r '.id // empty' 2>/dev/null || true)
|
||||
fi
|
||||
if [ -n "$label_id" ]; then
|
||||
_ILC_LABEL_IDS["$name"]="$label_id"
|
||||
fi
|
||||
printf '%s' "$label_id"
|
||||
}
|
||||
|
||||
_ilc_backlog_id() { _ilc_ensure_label_id "backlog" "#0075ca"; }
|
||||
_ilc_in_progress_id() { _ilc_ensure_label_id "in-progress" "#1d76db"; }
|
||||
_ilc_blocked_id() { _ilc_ensure_label_id "blocked" "#e11d48"; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# issue_claim — assign issue to bot, add "in-progress" label, remove "backlog".
|
||||
# Args: issue_number
|
||||
# Returns: 0 on success, 1 if already assigned to another agent
|
||||
# ---------------------------------------------------------------------------
|
||||
issue_claim() {
|
||||
local issue="$1"
|
||||
|
||||
# Get current bot identity
|
||||
local me
|
||||
me=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_URL}/api/v1/user" | jq -r '.login') || return 1
|
||||
|
||||
# Check current assignee
|
||||
local current
|
||||
current=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues/${issue}" | jq -r '.assignee.login // ""') || return 1
|
||||
|
||||
if [ -n "$current" ] && [ "$current" != "$me" ]; then
|
||||
_ilc_log "issue #${issue} already assigned to ${current} — skipping"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Assign to self (Forgejo rejects if already assigned differently)
|
||||
curl -sf -X PATCH \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/issues/${issue}" \
|
||||
-d "{\"assignees\":[\"${me}\"]}" >/dev/null 2>&1 || return 1
|
||||
|
||||
local ip_id bl_id
|
||||
ip_id=$(_ilc_in_progress_id)
|
||||
bl_id=$(_ilc_backlog_id)
|
||||
if [ -n "$ip_id" ]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/issues/${issue}/labels" \
|
||||
-d "{\"labels\":[${ip_id}]}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ -n "$bl_id" ]; then
|
||||
curl -sf -X DELETE \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues/${issue}/labels/${bl_id}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
_ilc_log "claimed issue #${issue}"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# issue_release — remove "in-progress" label, add "backlog" label, clear assignee.
|
||||
# Args: issue_number
|
||||
# ---------------------------------------------------------------------------
|
||||
issue_release() {
|
||||
local issue="$1"
|
||||
|
||||
# Clear assignee
|
||||
curl -sf -X PATCH \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/issues/${issue}" \
|
||||
-d '{"assignees":[]}' >/dev/null 2>&1 || true
|
||||
|
||||
local ip_id bl_id
|
||||
ip_id=$(_ilc_in_progress_id)
|
||||
bl_id=$(_ilc_backlog_id)
|
||||
if [ -n "$ip_id" ]; then
|
||||
curl -sf -X DELETE \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues/${issue}/labels/${ip_id}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ -n "$bl_id" ]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/issues/${issue}/labels" \
|
||||
-d "{\"labels\":[${bl_id}]}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
_ilc_log "released issue #${issue}"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _ilc_post_comment — Post a comment to an issue (internal helper)
|
||||
# Args: issue_number body_text
|
||||
# Uses a temp file to avoid large inline strings.
|
||||
# ---------------------------------------------------------------------------
|
||||
_ilc_post_comment() {
|
||||
local issue="$1" body="$2"
|
||||
|
||||
local tmpfile tmpjson
|
||||
tmpfile=$(mktemp /tmp/ilc-comment-XXXXXX.md)
|
||||
tmpjson="${tmpfile}.json"
|
||||
printf '%s' "$body" > "$tmpfile"
|
||||
jq -Rs '{body:.}' < "$tmpfile" > "$tmpjson"
|
||||
curl -sf -o /dev/null -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/issues/${issue}/comments" \
|
||||
--data-binary @"$tmpjson" 2>/dev/null || true
|
||||
rm -f "$tmpfile" "$tmpjson"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# issue_block — add "blocked" label, post diagnostic comment, remove in-progress.
|
||||
# Args: issue_number reason [result_text]
|
||||
# The result_text (e.g. tmux pane capture) is redacted for secrets before posting.
|
||||
# ---------------------------------------------------------------------------
|
||||
issue_block() {
|
||||
local issue="$1" reason="$2" result_text="${3:-}"
|
||||
|
||||
# Redact secrets from result text before posting to a public issue
|
||||
if [ -n "$result_text" ]; then
|
||||
result_text=$(redact_secrets "$result_text")
|
||||
fi
|
||||
|
||||
# Build diagnostic comment via temp file (avoids large inline strings)
|
||||
local tmpfile
|
||||
tmpfile=$(mktemp /tmp/ilc-block-XXXXXX.md)
|
||||
{
|
||||
printf '### Blocked — issue #%s\n\n' "$issue"
|
||||
printf '| Field | Value |\n|---|---|\n'
|
||||
printf '| Exit reason | `%s` |\n' "$reason"
|
||||
printf '| Timestamp | `%s` |\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
if [ -n "$result_text" ]; then
|
||||
printf '\n<details><summary>Diagnostic output</summary>\n\n```\n%s\n```\n</details>\n' "$result_text"
|
||||
fi
|
||||
} > "$tmpfile"
|
||||
|
||||
# Post comment using shared helper
|
||||
_ilc_post_comment "$issue" "$(cat "$tmpfile")"
|
||||
rm -f "$tmpfile"
|
||||
|
||||
# Remove in-progress, add blocked
|
||||
local ip_id bk_id
|
||||
ip_id=$(_ilc_in_progress_id)
|
||||
bk_id=$(_ilc_blocked_id)
|
||||
if [ -n "$ip_id" ]; then
|
||||
curl -sf -X DELETE \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues/${issue}/labels/${ip_id}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ -n "$bk_id" ]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/issues/${issue}/labels" \
|
||||
-d "{\"labels\":[${bk_id}]}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
_ilc_log "blocked issue #${issue}: ${reason}"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# issue_close — clear assignee, PATCH state to closed.
|
||||
# Args: issue_number
|
||||
# ---------------------------------------------------------------------------
|
||||
issue_close() {
|
||||
local issue="$1"
|
||||
|
||||
# Clear assignee before closing
|
||||
curl -sf -X PATCH \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/issues/${issue}" \
|
||||
-d '{"assignees":[]}' >/dev/null 2>&1 || true
|
||||
|
||||
curl -sf -X PATCH \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/issues/${issue}" \
|
||||
-d '{"state":"closed"}' >/dev/null 2>&1 || true
|
||||
_ilc_log "closed issue #${issue}"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# issue_check_deps — parse Depends-on from issue body, check transitive deps.
|
||||
# Args: issue_number
|
||||
# Sets: _ISSUE_BLOCKED_BY (array), _ISSUE_SUGGESTION (string or empty)
|
||||
# Returns: 0 if ready (all deps closed), 1 if blocked
|
||||
# ---------------------------------------------------------------------------
|
||||
# shellcheck disable=SC2034 # output vars read by callers
|
||||
issue_check_deps() {
|
||||
local issue="$1"
|
||||
_ISSUE_BLOCKED_BY=()
|
||||
_ISSUE_SUGGESTION=""
|
||||
|
||||
# Fetch issue body
|
||||
local issue_body
|
||||
issue_body=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues/${issue}" | jq -r '.body // ""') || true
|
||||
|
||||
if [ -z "$issue_body" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Extract dep numbers via shared parser
|
||||
local dep_numbers
|
||||
dep_numbers=$(printf '%s' "$issue_body" | bash "${FACTORY_ROOT}/lib/parse-deps.sh") || true
|
||||
|
||||
if [ -z "$dep_numbers" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check each direct dependency
|
||||
while IFS= read -r dep_num; do
|
||||
[ -z "$dep_num" ] && continue
|
||||
local dep_state
|
||||
dep_state=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues/${dep_num}" | jq -r '.state // "unknown"') || true
|
||||
if [ "$dep_state" != "closed" ]; then
|
||||
_ISSUE_BLOCKED_BY+=("$dep_num")
|
||||
fi
|
||||
done <<< "$dep_numbers"
|
||||
|
||||
if [ "${#_ISSUE_BLOCKED_BY[@]}" -eq 0 ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Find suggestion: first open blocker whose own deps are all met
|
||||
local blocker
|
||||
for blocker in "${_ISSUE_BLOCKED_BY[@]}"; do
|
||||
local blocker_json blocker_state blocker_body
|
||||
blocker_json=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues/${blocker}") || continue
|
||||
blocker_state=$(printf '%s' "$blocker_json" | jq -r '.state') || continue
|
||||
[ "$blocker_state" != "open" ] && continue
|
||||
|
||||
blocker_body=$(printf '%s' "$blocker_json" | jq -r '.body // ""')
|
||||
local blocker_deps
|
||||
blocker_deps=$(printf '%s' "$blocker_body" | bash "${FACTORY_ROOT}/lib/parse-deps.sh") || true
|
||||
|
||||
local blocker_blocked=false
|
||||
if [ -n "$blocker_deps" ]; then
|
||||
local bd
|
||||
while IFS= read -r bd; do
|
||||
[ -z "$bd" ] && continue
|
||||
local bd_state
|
||||
bd_state=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues/${bd}" | jq -r '.state // "unknown"') || true
|
||||
if [ "$bd_state" != "closed" ]; then
|
||||
blocker_blocked=true
|
||||
break
|
||||
fi
|
||||
done <<< "$blocker_deps"
|
||||
fi
|
||||
|
||||
if [ "$blocker_blocked" = false ]; then
|
||||
_ISSUE_SUGGESTION="$blocker"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
_ilc_log "issue #${issue} blocked by: ${_ISSUE_BLOCKED_BY[*]}$([ -n "$_ISSUE_SUGGESTION" ] && printf ', suggest #%s' "$_ISSUE_SUGGESTION")"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# issue_suggest_next — find next unblocked backlog issue.
|
||||
# Sets: _ISSUE_NEXT (string or empty)
|
||||
# Returns: 0 if found, 1 if none available
|
||||
# ---------------------------------------------------------------------------
|
||||
# shellcheck disable=SC2034 # output vars read by callers
|
||||
issue_suggest_next() {
|
||||
_ISSUE_NEXT=""
|
||||
|
||||
local issues_json
|
||||
issues_json=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues?state=open&labels=backlog&limit=20&type=issues") || true
|
||||
|
||||
if [ -z "$issues_json" ] || [ "$issues_json" = "null" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local issue_nums
|
||||
issue_nums=$(printf '%s' "$issues_json" | jq -r '.[].number') || true
|
||||
|
||||
local num
|
||||
while IFS= read -r num; do
|
||||
[ -z "$num" ] && continue
|
||||
local body dep_nums
|
||||
body=$(printf '%s' "$issues_json" | \
|
||||
jq -r --argjson n "$num" '.[] | select(.number == $n) | .body // ""')
|
||||
dep_nums=$(printf '%s' "$body" | bash "${FACTORY_ROOT}/lib/parse-deps.sh") || true
|
||||
|
||||
local all_met=true
|
||||
if [ -n "$dep_nums" ]; then
|
||||
local dep
|
||||
while IFS= read -r dep; do
|
||||
[ -z "$dep" ] && continue
|
||||
local dep_state
|
||||
dep_state=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues/${dep}" | jq -r '.state // "open"') || dep_state="open"
|
||||
if [ "$dep_state" != "closed" ]; then
|
||||
all_met=false
|
||||
break
|
||||
fi
|
||||
done <<< "$dep_nums"
|
||||
fi
|
||||
|
||||
if [ "$all_met" = true ]; then
|
||||
_ISSUE_NEXT="$num"
|
||||
_ilc_log "next unblocked issue: #${num}"
|
||||
return 0
|
||||
fi
|
||||
done <<< "$issue_nums"
|
||||
|
||||
_ilc_log "no unblocked backlog issues found"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# issue_post_refusal — post structured refusal comment with dedup check.
|
||||
# Args: issue_number emoji title body
|
||||
# ---------------------------------------------------------------------------
|
||||
issue_post_refusal() {
|
||||
local issue="$1" emoji="$2" title="$3" body="$4"
|
||||
|
||||
# Dedup: skip if recent comments already contain this title
|
||||
local last_has_title
|
||||
last_has_title=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues/${issue}/comments?limit=5" | \
|
||||
jq -r --arg t "Dev-agent: ${title}" \
|
||||
'[.[] | .body // ""] | any(contains($t)) | tostring') || true
|
||||
if [ "$last_has_title" = "true" ]; then
|
||||
_ilc_log "skipping duplicate refusal comment: ${title}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local comment tmpfile
|
||||
comment="${emoji} **Dev-agent: ${title}**
|
||||
|
||||
${body}
|
||||
|
||||
---
|
||||
*Automated assessment by dev-agent · $(date -u '+%Y-%m-%d %H:%M UTC')*"
|
||||
|
||||
tmpfile=$(mktemp /tmp/ilc-refusal-XXXXXX.txt)
|
||||
printf '%s' "$comment" > "$tmpfile"
|
||||
jq -Rs '{body: .}' < "$tmpfile" > "${tmpfile}.json"
|
||||
curl -sf -o /dev/null -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/issues/${issue}/comments" \
|
||||
--data-binary @"${tmpfile}.json" 2>/dev/null || \
|
||||
_ilc_log "WARNING: failed to post refusal comment on issue #${issue}"
|
||||
rm -f "$tmpfile" "${tmpfile}.json"
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
# PROJECT_CONTAINERS, CHECK_PRS, CHECK_DEV_AGENT,
|
||||
# CHECK_PIPELINE_STALL, CI_STALE_MINUTES,
|
||||
# MIRROR_NAMES, MIRROR_URLS, MIRROR_<NAME> (per configured mirror)
|
||||
# (plus backwards-compat aliases: CODEBERG_REPO, CODEBERG_API, CODEBERG_WEB)
|
||||
#
|
||||
# If no argument given, does nothing (allows poll scripts to work with
|
||||
# plain .env fallback for backwards compatibility).
|
||||
|
|
@ -79,17 +80,9 @@ if mirrors:
|
|||
return 1 2>/dev/null || exit 1
|
||||
}
|
||||
|
||||
# Export parsed variables.
|
||||
# Inside the agents container (DISINTO_CONTAINER=1), compose already sets the
|
||||
# correct FORGE_URL (http://forgejo:3000) and path vars for the container
|
||||
# environment. The TOML carries host-perspective values (localhost, /home/admin/…)
|
||||
# that would break container API calls and path resolution. Skip overriding
|
||||
# any env var that is already set when running inside the container.
|
||||
# Export parsed variables
|
||||
while IFS='=' read -r _key _val; do
|
||||
[ -z "$_key" ] && continue
|
||||
if [ "${DISINTO_CONTAINER:-}" = "1" ] && [ -n "${!_key:-}" ]; then
|
||||
continue
|
||||
fi
|
||||
export "$_key=$_val"
|
||||
done <<< "$_PROJECT_VARS"
|
||||
|
||||
|
|
@ -99,9 +92,11 @@ export FORGE_URL="${FORGE_URL:-http://localhost:3000}"
|
|||
if [ -n "$FORGE_REPO" ]; then
|
||||
export FORGE_API="${FORGE_URL}/api/v1/repos/${FORGE_REPO}"
|
||||
export FORGE_WEB="${FORGE_URL}/${FORGE_REPO}"
|
||||
# Extract repo owner (first path segment of owner/repo)
|
||||
export FORGE_REPO_OWNER="${FORGE_REPO%%/*}"
|
||||
fi
|
||||
# Backwards-compat aliases
|
||||
export CODEBERG_REPO="${FORGE_REPO}"
|
||||
export CODEBERG_API="${FORGE_API:-}"
|
||||
export CODEBERG_WEB="${FORGE_WEB:-}"
|
||||
|
||||
# Derive PROJECT_REPO_ROOT if not explicitly set
|
||||
if [ -z "${PROJECT_REPO_ROOT:-}" ] && [ -n "${PROJECT_NAME:-}" ]; then
|
||||
|
|
|
|||
|
|
@ -13,16 +13,7 @@ mirror_push() {
|
|||
|
||||
local name url
|
||||
for name in $MIRROR_NAMES; do
|
||||
# Convert name to uppercase env var name safely (only alphanumeric allowed)
|
||||
local upper_name
|
||||
upper_name=$(printf '%s' "$name" | tr '[:lower:]' '[:upper:]')
|
||||
# Validate: only allow alphanumeric + underscore in var name
|
||||
if [[ ! "$upper_name" =~ ^[A-Z_][A-Z0-9_]*$ ]]; then
|
||||
continue
|
||||
fi
|
||||
# Use indirect expansion safely (no eval) — MIRROR_ prefix required
|
||||
local varname="MIRROR_${upper_name}"
|
||||
url="${!varname:-}"
|
||||
url=$(eval "echo \"\$MIRROR_$(echo "$name" | tr '[:lower:]' '[:upper:]')\"") || true
|
||||
[ -z "$url" ] && continue
|
||||
|
||||
# Ensure remote exists with correct URL
|
||||
|
|
|
|||
225
lib/ops-setup.sh
225
lib/ops-setup.sh
|
|
@ -1,225 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# ops-setup.sh — Setup ops repository (disinto-ops)
|
||||
#
|
||||
# Source from bin/disinto:
|
||||
# source "$(dirname "$0")/../lib/ops-setup.sh"
|
||||
#
|
||||
# Required globals: FORGE_URL, FORGE_TOKEN, FACTORY_ROOT
|
||||
# Optional: admin_token (falls back to FORGE_TOKEN for admin operations)
|
||||
#
|
||||
# Functions:
|
||||
# setup_ops_repo <forge_url> <ops_slug> <ops_root> [primary_branch]
|
||||
# - Create ops repo on Forgejo if it doesn't exist
|
||||
# - Configure bot collaborators with appropriate permissions
|
||||
# - Clone or initialize ops repo locally
|
||||
# - Seed directory structure (vault, knowledge, evidence)
|
||||
# - Export _ACTUAL_OPS_SLUG for caller to use
|
||||
#
|
||||
# Globals modified:
|
||||
# _ACTUAL_OPS_SLUG - resolved ops repo slug after function completes
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
setup_ops_repo() {
|
||||
|
||||
local forge_url="$1" ops_slug="$2" ops_root="$3" primary_branch="${4:-main}"
|
||||
local org_name="${ops_slug%%/*}"
|
||||
local ops_name="${ops_slug##*/}"
|
||||
|
||||
echo ""
|
||||
echo "── Ops repo setup ─────────────────────────────────────"
|
||||
|
||||
# Determine the actual ops repo location by searching across possible namespaces
|
||||
# This handles cases where the repo was created under a different namespace
|
||||
# due to past bugs (e.g., dev-bot/disinto-ops instead of disinto-admin/disinto-ops)
|
||||
local actual_ops_slug=""
|
||||
local -a possible_namespaces=( "$org_name" "dev-bot" "disinto-admin" )
|
||||
local http_code
|
||||
|
||||
for ns in "${possible_namespaces[@]}"; do
|
||||
slug="${ns}/${ops_name}"
|
||||
if curl -sf --max-time 5 \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${forge_url}/api/v1/repos/${slug}" >/dev/null 2>&1; then
|
||||
actual_ops_slug="$slug"
|
||||
echo "Ops repo: ${slug} (found at ${slug})"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# If not found, try to create it in the configured namespace
|
||||
if [ -z "$actual_ops_slug" ]; then
|
||||
echo "Creating ops repo in namespace: ${org_name}"
|
||||
# Create org if it doesn't exist
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/orgs" \
|
||||
-d "{\"username\":\"${org_name}\",\"visibility\":\"public\"}" >/dev/null 2>&1 || true
|
||||
if curl -sf -X POST \
|
||||
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/orgs/${org_name}/repos" \
|
||||
-d "{\"name\":\"${ops_name}\",\"auto_init\":true,\"default_branch\":\"${primary_branch}\",\"description\":\"Operational data for ${org_name}/${ops_name%-ops}\"}" >/dev/null 2>&1; then
|
||||
actual_ops_slug="${org_name}/${ops_name}"
|
||||
echo "Ops repo: ${actual_ops_slug} created on Forgejo"
|
||||
else
|
||||
# Fallback: use admin API to create repo under the target namespace
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/admin/users/${org_name}/repos" \
|
||||
-d "{\"name\":\"${ops_name}\",\"auto_init\":true,\"default_branch\":\"${primary_branch}\",\"description\":\"Operational data for ${org_name}/${ops_name%-ops}\"}" 2>/dev/null || echo "0")
|
||||
if [ "$http_code" = "201" ]; then
|
||||
actual_ops_slug="${org_name}/${ops_name}"
|
||||
echo "Ops repo: ${actual_ops_slug} created on Forgejo (via admin API)"
|
||||
else
|
||||
echo "Error: failed to create ops repo '${org_name}/${ops_name}' (HTTP ${http_code})" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Configure collaborators on the ops repo
|
||||
local bot_user bot_perm
|
||||
declare -A bot_permissions=(
|
||||
[dev-bot]="write"
|
||||
[review-bot]="read"
|
||||
[planner-bot]="write"
|
||||
[gardener-bot]="write"
|
||||
[vault-bot]="write"
|
||||
[supervisor-bot]="read"
|
||||
[predictor-bot]="read"
|
||||
[architect-bot]="write"
|
||||
)
|
||||
|
||||
# Add all bot users as collaborators with appropriate permissions
|
||||
# vault branch protection (#77) requires:
|
||||
# - Admin-only merge to main (enforced by admin_enforced: true)
|
||||
# - Bots can push branches and create PRs, but cannot merge
|
||||
for bot_user in "${!bot_permissions[@]}"; do
|
||||
bot_perm="${bot_permissions[$bot_user]}"
|
||||
if curl -sf -X PUT \
|
||||
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/repos/${actual_ops_slug}/collaborators/${bot_user}" \
|
||||
-d "{\"permission\":\"${bot_perm}\"}" >/dev/null 2>&1; then
|
||||
echo " + ${bot_user} = ${bot_perm} collaborator"
|
||||
else
|
||||
echo " ! ${bot_user} = ${bot_perm} (already set or failed)"
|
||||
fi
|
||||
done
|
||||
|
||||
# Add disinto-admin as admin collaborator
|
||||
if curl -sf -X PUT \
|
||||
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/repos/${actual_ops_slug}/collaborators/disinto-admin" \
|
||||
-d '{"permission":"admin"}' >/dev/null 2>&1; then
|
||||
echo " + disinto-admin = admin collaborator"
|
||||
else
|
||||
echo " ! disinto-admin = admin (already set or failed)"
|
||||
fi
|
||||
|
||||
# Clone ops repo locally if not present
|
||||
if [ ! -d "${ops_root}/.git" ]; then
|
||||
local auth_url
|
||||
auth_url=$(printf '%s' "$forge_url" | sed "s|://|://dev-bot:${FORGE_TOKEN}@|")
|
||||
local clone_url="${auth_url}/${actual_ops_slug}.git"
|
||||
echo "Cloning: ops repo -> ${ops_root}"
|
||||
if git clone --quiet "$clone_url" "$ops_root" 2>/dev/null; then
|
||||
echo "Ops repo: ${actual_ops_slug} cloned successfully"
|
||||
else
|
||||
echo "Initializing: ops repo at ${ops_root}"
|
||||
mkdir -p "$ops_root"
|
||||
git -C "$ops_root" init --initial-branch="${primary_branch}" -q
|
||||
# Set remote to the actual ops repo location
|
||||
git -C "$ops_root" remote add origin "${forge_url}/${actual_ops_slug}.git"
|
||||
echo "Ops repo: ${actual_ops_slug} initialized locally"
|
||||
fi
|
||||
else
|
||||
echo "Ops repo: ${ops_root} (already exists locally)"
|
||||
# Verify remote is correct
|
||||
local current_remote
|
||||
current_remote=$(git -C "$ops_root" remote get-url origin 2>/dev/null || true)
|
||||
local expected_remote="${forge_url}/${actual_ops_slug}.git"
|
||||
if [ -n "$current_remote" ] && [ "$current_remote" != "$expected_remote" ]; then
|
||||
echo " Fixing: remote URL from ${current_remote} to ${expected_remote}"
|
||||
git -C "$ops_root" remote set-url origin "$expected_remote"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Seed directory structure
|
||||
local seeded=false
|
||||
mkdir -p "${ops_root}/vault/pending"
|
||||
mkdir -p "${ops_root}/vault/approved"
|
||||
mkdir -p "${ops_root}/vault/fired"
|
||||
mkdir -p "${ops_root}/vault/rejected"
|
||||
mkdir -p "${ops_root}/knowledge"
|
||||
mkdir -p "${ops_root}/evidence/engagement"
|
||||
|
||||
if [ ! -f "${ops_root}/README.md" ]; then
|
||||
cat > "${ops_root}/README.md" <<OPSEOF
|
||||
# ${ops_name}
|
||||
|
||||
Operational data for the ${ops_name%-ops} project.
|
||||
|
||||
## Structure
|
||||
|
||||
\`\`\`
|
||||
${ops_name}/
|
||||
├── vault/
|
||||
│ ├── pending/ # vault items awaiting approval
|
||||
│ ├── approved/ # approved vault items
|
||||
│ ├── fired/ # executed vault items
|
||||
│ └── rejected/ # rejected vault items
|
||||
├── knowledge/ # shared agent knowledge and best practices
|
||||
├── evidence/ # engagement data, experiment results
|
||||
├── portfolio.md # addressables + observables
|
||||
├── prerequisites.md # dependency graph
|
||||
└── RESOURCES.md # accounts, tokens (refs), infra inventory
|
||||
\`\`\`
|
||||
|
||||
> **Note:** Journal directories (journal/planner/ and journal/supervisor/) have been removed from the ops repo. Agent journals are now stored in each agent's .profile repo on Forgejo.
|
||||
|
||||
## Branch protection
|
||||
|
||||
- \`main\`: 2 reviewers required for vault items
|
||||
- Journal/evidence commits may use lighter rules
|
||||
OPSEOF
|
||||
seeded=true
|
||||
fi
|
||||
|
||||
# Create stub files if they don't exist
|
||||
[ -f "${ops_root}/portfolio.md" ] || { echo "# Portfolio" > "${ops_root}/portfolio.md"; seeded=true; }
|
||||
[ -f "${ops_root}/prerequisites.md" ] || { echo "# Prerequisite Tree" > "${ops_root}/prerequisites.md"; seeded=true; }
|
||||
[ -f "${ops_root}/RESOURCES.md" ] || { echo "# Resources" > "${ops_root}/RESOURCES.md"; seeded=true; }
|
||||
|
||||
# Commit and push seed content
|
||||
if [ "$seeded" = true ] && [ -d "${ops_root}/.git" ]; then
|
||||
# Auto-configure repo-local git identity if missing (#778)
|
||||
if [ -z "$(git -C "$ops_root" config user.name 2>/dev/null)" ]; then
|
||||
git -C "$ops_root" config user.name "disinto-admin"
|
||||
fi
|
||||
if [ -z "$(git -C "$ops_root" config user.email 2>/dev/null)" ]; then
|
||||
git -C "$ops_root" config user.email "disinto-admin@localhost"
|
||||
fi
|
||||
|
||||
git -C "$ops_root" add -A
|
||||
if ! git -C "$ops_root" diff --cached --quiet 2>/dev/null; then
|
||||
git -C "$ops_root" commit -m "chore: seed ops repo structure" -q
|
||||
# Push if remote exists
|
||||
if git -C "$ops_root" remote get-url origin >/dev/null 2>&1; then
|
||||
if git -C "$ops_root" push origin "${primary_branch}" -q 2>/dev/null; then
|
||||
echo "Seeded: ops repo with initial structure"
|
||||
else
|
||||
echo "Warning: failed to push seed content to ops repo" >&2
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Export resolved slug for the caller to write back to the project TOML
|
||||
_ACTUAL_OPS_SLUG="${actual_ops_slug}"
|
||||
}
|
||||
|
|
@ -1,561 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# pr-lifecycle.sh — Reusable PR lifecycle library: create, poll, review, merge
|
||||
#
|
||||
# Source after lib/env.sh and lib/ci-helpers.sh:
|
||||
# source "$FACTORY_ROOT/lib/ci-helpers.sh"
|
||||
# source "$FACTORY_ROOT/lib/pr-lifecycle.sh"
|
||||
#
|
||||
# Required globals: FORGE_TOKEN, FORGE_API, PRIMARY_BRANCH
|
||||
# Optional: FORGE_REMOTE (default: origin), WOODPECKER_REPO_ID,
|
||||
# WOODPECKER_TOKEN, WOODPECKER_SERVER, FACTORY_ROOT
|
||||
#
|
||||
# For pr_walk_to_merge(): caller must define agent_run() — a synchronous Claude
|
||||
# invocation (one-shot claude -p). Expected signature:
|
||||
# agent_run [--resume SESSION] [--worktree DIR] PROMPT
|
||||
#
|
||||
# Functions:
|
||||
# pr_create BRANCH TITLE BODY [BASE_BRANCH]
|
||||
# pr_find_by_branch BRANCH
|
||||
# pr_poll_ci PR_NUMBER [TIMEOUT_SECS] [POLL_INTERVAL]
|
||||
# pr_poll_review PR_NUMBER [TIMEOUT_SECS] [POLL_INTERVAL]
|
||||
# pr_merge PR_NUMBER [COMMIT_MSG]
|
||||
# pr_is_merged PR_NUMBER
|
||||
# pr_walk_to_merge PR_NUMBER SESSION_ID WORKTREE [MAX_CI_FIXES] [MAX_REVIEW_ROUNDS]
|
||||
# build_phase_protocol_prompt BRANCH [REMOTE]
|
||||
#
|
||||
# Output variables (set by poll/merge functions, read by callers):
|
||||
# _PR_CI_STATE success | failure | timeout
|
||||
# _PR_CI_SHA commit SHA that was polled
|
||||
# _PR_CI_PIPELINE Woodpecker pipeline number (on failure)
|
||||
# _PR_CI_FAILURE_TYPE infra | code (on failure)
|
||||
# _PR_CI_ERROR_LOG CI error log snippet (on failure)
|
||||
# _PR_REVIEW_VERDICT APPROVE | REQUEST_CHANGES | DISCUSS | TIMEOUT |
|
||||
# MERGED_EXTERNALLY | CLOSED_EXTERNALLY
|
||||
# _PR_REVIEW_TEXT review feedback body text
|
||||
# _PR_MERGE_ERROR merge error description (on failure)
|
||||
# _PR_WALK_EXIT_REASON merged | ci_exhausted | review_exhausted |
|
||||
# ci_timeout | review_timeout | merge_blocked |
|
||||
# closed_externally | unexpected_verdict
|
||||
#
|
||||
# shellcheck shell=bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Default agent_run stub — callers override by defining agent_run() or sourcing
|
||||
# an SDK (e.g., lib/sdk.sh) after this file.
|
||||
if ! type agent_run &>/dev/null; then
|
||||
agent_run() {
|
||||
printf 'ERROR: agent_run() not defined — source your SDK before calling pr_walk_to_merge\n' >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Internal log helper.
|
||||
_prl_log() {
|
||||
if declare -f log >/dev/null 2>&1; then
|
||||
log "pr-lifecycle: $*"
|
||||
else
|
||||
printf '[%s] pr-lifecycle: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pr_create — Create a PR via forge API.
|
||||
# Args: branch title body [base_branch] [api_url]
|
||||
# Stdout: PR number
|
||||
# Returns: 0=created (or found existing), 1=failed
|
||||
# api_url defaults to FORGE_API if not provided
|
||||
# ---------------------------------------------------------------------------
|
||||
pr_create() {
|
||||
local branch="$1" title="$2" body="$3"
|
||||
local base="${4:-${PRIMARY_BRANCH:-main}}"
|
||||
local api_url="${5:-${FORGE_API}}"
|
||||
local tmpfile resp http_code resp_body pr_num
|
||||
|
||||
tmpfile=$(mktemp /tmp/prl-create-XXXXXX.json)
|
||||
jq -n --arg t "$title" --arg b "$body" --arg h "$branch" --arg base "$base" \
|
||||
'{title:$t, body:$b, head:$h, base:$base}' > "$tmpfile"
|
||||
|
||||
resp=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${api_url}/pulls" \
|
||||
--data-binary @"$tmpfile") || true
|
||||
rm -f "$tmpfile"
|
||||
|
||||
http_code=$(printf '%s\n' "$resp" | tail -1)
|
||||
resp_body=$(printf '%s\n' "$resp" | sed '$d')
|
||||
|
||||
case "$http_code" in
|
||||
200|201)
|
||||
pr_num=$(printf '%s' "$resp_body" | jq -r '.number')
|
||||
_prl_log "created PR #${pr_num}"
|
||||
printf '%s' "$pr_num"
|
||||
return 0
|
||||
;;
|
||||
409)
|
||||
pr_num=$(pr_find_by_branch "$branch" "$api_url") || true
|
||||
if [ -n "$pr_num" ]; then
|
||||
_prl_log "PR already exists: #${pr_num}"
|
||||
printf '%s' "$pr_num"
|
||||
return 0
|
||||
fi
|
||||
_prl_log "PR creation failed: 409 conflict, no existing PR found"
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
_prl_log "PR creation failed (HTTP ${http_code})"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pr_find_by_branch — Find an open PR by head branch name.
|
||||
# Args: branch [api_url]
|
||||
# Stdout: PR number
|
||||
# Returns: 0=found, 1=not found
|
||||
# api_url defaults to FORGE_API if not provided
|
||||
# ---------------------------------------------------------------------------
|
||||
pr_find_by_branch() {
|
||||
local branch="$1"
|
||||
local api_url="${2:-${FORGE_API}}"
|
||||
local pr_num
|
||||
pr_num=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${api_url}/pulls?state=open&limit=20" | \
|
||||
jq -r --arg b "$branch" '.[] | select(.head.ref == $b) | .number' \
|
||||
| head -1) || true
|
||||
if [ -n "$pr_num" ]; then
|
||||
printf '%s' "$pr_num"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pr_poll_ci — Poll CI status until complete or timeout.
|
||||
# Args: pr_number [timeout_secs=1800] [poll_interval=30]
|
||||
# Sets: _PR_CI_STATE _PR_CI_SHA _PR_CI_PIPELINE _PR_CI_FAILURE_TYPE _PR_CI_ERROR_LOG
|
||||
# Returns: 0=success, 1=failure, 2=timeout
|
||||
# ---------------------------------------------------------------------------
|
||||
# shellcheck disable=SC2034 # output vars read by callers
|
||||
pr_poll_ci() {
|
||||
local pr_num="$1"
|
||||
local timeout="${2:-1800}" interval="${3:-30}"
|
||||
local elapsed=0
|
||||
|
||||
_PR_CI_STATE="" ; _PR_CI_SHA="" ; _PR_CI_PIPELINE=""
|
||||
_PR_CI_FAILURE_TYPE="" ; _PR_CI_ERROR_LOG=""
|
||||
|
||||
_PR_CI_SHA=$(forge_api GET "/pulls/${pr_num}" | jq -r '.head.sha') || true
|
||||
if [ -z "$_PR_CI_SHA" ]; then
|
||||
_prl_log "cannot get HEAD SHA for PR #${pr_num}"
|
||||
_PR_CI_STATE="failure"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "${WOODPECKER_REPO_ID:-2}" = "0" ]; then
|
||||
_PR_CI_STATE="success"
|
||||
_prl_log "no CI configured"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! ci_required_for_pr "$pr_num"; then
|
||||
_PR_CI_STATE="success"
|
||||
_prl_log "PR #${pr_num} non-code — CI not required"
|
||||
return 0
|
||||
fi
|
||||
|
||||
_prl_log "polling CI for PR #${pr_num} SHA ${_PR_CI_SHA:0:7}"
|
||||
while [ "$elapsed" -lt "$timeout" ]; do
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
|
||||
local state
|
||||
state=$(ci_commit_status "$_PR_CI_SHA") || true
|
||||
case "$state" in
|
||||
success)
|
||||
_PR_CI_STATE="success"
|
||||
_prl_log "CI passed"
|
||||
return 0
|
||||
;;
|
||||
failure|error)
|
||||
_PR_CI_STATE="failure"
|
||||
_PR_CI_PIPELINE=$(ci_pipeline_number "$_PR_CI_SHA") || true
|
||||
if [ -n "$_PR_CI_PIPELINE" ] && [ -n "${WOODPECKER_REPO_ID:-}" ]; then
|
||||
_PR_CI_FAILURE_TYPE=$(classify_pipeline_failure \
|
||||
"$WOODPECKER_REPO_ID" "$_PR_CI_PIPELINE" 2>/dev/null \
|
||||
| cut -d' ' -f1) || _PR_CI_FAILURE_TYPE="code"
|
||||
if [ -n "${FACTORY_ROOT:-}" ]; then
|
||||
_PR_CI_ERROR_LOG=$(bash "${FACTORY_ROOT}/lib/ci-debug.sh" \
|
||||
failures "$_PR_CI_PIPELINE" 2>/dev/null \
|
||||
| tail -80 | head -c 8000) || true
|
||||
fi
|
||||
fi
|
||||
_prl_log "CI failed (type: ${_PR_CI_FAILURE_TYPE:-unknown})"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
_PR_CI_STATE="timeout"
|
||||
_prl_log "CI timeout after ${timeout}s"
|
||||
return 2
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pr_poll_review — Poll for review verdict on a PR.
|
||||
# Args: pr_number [timeout_secs=10800] [poll_interval=300]
|
||||
# Sets: _PR_REVIEW_VERDICT _PR_REVIEW_TEXT
|
||||
# Returns: 0=verdict found, 1=timeout, 2=PR closed/merged externally
|
||||
# ---------------------------------------------------------------------------
|
||||
# shellcheck disable=SC2034 # output vars read by callers
|
||||
pr_poll_review() {
|
||||
local pr_num="$1"
|
||||
local timeout="${2:-10800}" interval="${3:-300}"
|
||||
local elapsed=0
|
||||
|
||||
_PR_REVIEW_VERDICT="" ; _PR_REVIEW_TEXT=""
|
||||
|
||||
_prl_log "polling review for PR #${pr_num}"
|
||||
while [ "$elapsed" -lt "$timeout" ]; do
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
|
||||
local pr_json sha
|
||||
pr_json=$(forge_api GET "/pulls/${pr_num}") || true
|
||||
sha=$(printf '%s' "$pr_json" | jq -r '.head.sha // empty') || true
|
||||
|
||||
# Check if PR closed/merged externally
|
||||
local pr_state pr_merged
|
||||
pr_state=$(printf '%s' "$pr_json" | jq -r '.state // "unknown"')
|
||||
pr_merged=$(printf '%s' "$pr_json" | jq -r '.merged // false')
|
||||
if [ "$pr_state" != "open" ]; then
|
||||
if [ "$pr_merged" = "true" ]; then
|
||||
_PR_REVIEW_VERDICT="MERGED_EXTERNALLY"
|
||||
_prl_log "PR #${pr_num} merged externally"
|
||||
return 2
|
||||
fi
|
||||
_PR_REVIEW_VERDICT="CLOSED_EXTERNALLY"
|
||||
_prl_log "PR #${pr_num} closed externally"
|
||||
return 2
|
||||
fi
|
||||
|
||||
# Check bot review comment (<!-- reviewed: SHA -->)
|
||||
local review_comment review_text="" verdict=""
|
||||
review_comment=$(forge_api_all "/issues/${pr_num}/comments" | \
|
||||
jq -r --arg sha "${sha:-}" \
|
||||
'[.[] | select(.body | contains("<!-- reviewed: " + $sha))] | last // empty') || true
|
||||
|
||||
if [ -n "$review_comment" ] && [ "$review_comment" != "null" ]; then
|
||||
review_text=$(printf '%s' "$review_comment" | jq -r '.body')
|
||||
# Skip error reviews
|
||||
if printf '%s' "$review_text" | grep -q 'review-error\|Review — Error'; then
|
||||
_prl_log "review error — waiting for re-review"
|
||||
continue
|
||||
fi
|
||||
verdict=$(printf '%s' "$review_text" | \
|
||||
grep -oP '\*\*(APPROVE|REQUEST_CHANGES|DISCUSS)\*\*' | head -1 | tr -d '*') || true
|
||||
fi
|
||||
|
||||
# Fallback: formal forge reviews
|
||||
if [ -z "$verdict" ]; then
|
||||
verdict=$(forge_api GET "/pulls/${pr_num}/reviews" | \
|
||||
jq -r '[.[] | select(.stale == false)] | last | .state // empty') || true
|
||||
case "$verdict" in
|
||||
APPROVED) verdict="APPROVE" ;;
|
||||
REQUEST_CHANGES) ;;
|
||||
*) verdict="" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ -n "$verdict" ]; then
|
||||
_PR_REVIEW_VERDICT="$verdict"
|
||||
_PR_REVIEW_TEXT="${review_text:-}"
|
||||
_prl_log "review verdict: ${verdict}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
_prl_log "waiting for review on PR #${pr_num} (${elapsed}s)"
|
||||
done
|
||||
|
||||
_PR_REVIEW_VERDICT="TIMEOUT"
|
||||
_prl_log "review timeout after ${timeout}s"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pr_merge — Merge a PR via forge API.
|
||||
# Args: pr_number [commit_message]
|
||||
# Sets: _PR_MERGE_ERROR (on failure)
|
||||
# Returns: 0=merged, 1=error, 2=blocked (HTTP 405)
|
||||
# ---------------------------------------------------------------------------
|
||||
# shellcheck disable=SC2034 # _PR_MERGE_ERROR read by callers
|
||||
pr_merge() {
|
||||
local pr_num="$1" commit_msg="${2:-}"
|
||||
local merge_data resp http_code body
|
||||
|
||||
_PR_MERGE_ERROR=""
|
||||
|
||||
merge_data='{"Do":"merge","delete_branch_after_merge":true}'
|
||||
if [ -n "$commit_msg" ]; then
|
||||
merge_data=$(jq -nc --arg m "$commit_msg" \
|
||||
'{Do:"merge",delete_branch_after_merge:true,MergeMessageField:$m}')
|
||||
fi
|
||||
|
||||
resp=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${FORGE_API}/pulls/${pr_num}/merge" \
|
||||
-d "$merge_data") || true
|
||||
http_code=$(printf '%s\n' "$resp" | tail -1)
|
||||
body=$(printf '%s\n' "$resp" | sed '$d')
|
||||
|
||||
case "$http_code" in
|
||||
200|204)
|
||||
_prl_log "PR #${pr_num} merged"
|
||||
return 0
|
||||
;;
|
||||
405)
|
||||
# Check if already merged (race with another agent)
|
||||
local merged
|
||||
merged=$(forge_api GET "/pulls/${pr_num}" | jq -r '.merged // false') || true
|
||||
if [ "$merged" = "true" ]; then
|
||||
_prl_log "PR #${pr_num} already merged"
|
||||
return 0
|
||||
fi
|
||||
_PR_MERGE_ERROR="blocked (HTTP 405): ${body:0:200}"
|
||||
_prl_log "PR #${pr_num} merge blocked: ${_PR_MERGE_ERROR}"
|
||||
return 2
|
||||
;;
|
||||
*)
|
||||
_PR_MERGE_ERROR="failed (HTTP ${http_code}): ${body:0:200}"
|
||||
_prl_log "PR #${pr_num} merge failed: ${_PR_MERGE_ERROR}"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pr_is_merged — Check if a PR is merged.
|
||||
# Args: pr_number
|
||||
# Returns: 0=merged, 1=not merged
|
||||
# ---------------------------------------------------------------------------
|
||||
pr_is_merged() {
|
||||
local pr_num="$1"
|
||||
local merged
|
||||
merged=$(forge_api GET "/pulls/${pr_num}" | jq -r '.merged // false') || true
|
||||
[ "$merged" = "true" ]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pr_close — Close a PR via forge API.
|
||||
# Args: pr_number
|
||||
# Returns: 0=closed, 1=error
|
||||
# ---------------------------------------------------------------------------
|
||||
pr_close() {
|
||||
local pr_num="$1"
|
||||
|
||||
_prl_log "closing PR #${pr_num}"
|
||||
local resp http_code
|
||||
resp=$(curl -sf -w "\n%{http_code}" -X PATCH \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/pulls/${pr_num}" \
|
||||
-d '{"state":"closed"}' 2>/dev/null) || true
|
||||
http_code=$(printf '%s\n' "$resp" | tail -1)
|
||||
if [ "$http_code" != "200" ] && [ "$http_code" != "204" ]; then
|
||||
_prl_log "pr_close FAILED: HTTP ${http_code} for PR #${pr_num}"
|
||||
return 1
|
||||
fi
|
||||
_prl_log "PR #${pr_num} closed"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pr_walk_to_merge — Walk a PR through CI, review, and merge.
|
||||
#
|
||||
# Requires agent_run() defined by the caller (synchronous Claude invocation).
|
||||
# The orchestrator bash loop IS the state machine — no phase files needed.
|
||||
#
|
||||
# Args: pr_number session_id worktree [max_ci_fixes=3] [max_review_rounds=5]
|
||||
# Returns: 0=merged, 1=exhausted or unrecoverable failure
|
||||
# Sets: _PR_WALK_EXIT_REASON
|
||||
# ---------------------------------------------------------------------------
|
||||
# shellcheck disable=SC2034 # _PR_WALK_EXIT_REASON read by callers
|
||||
pr_walk_to_merge() {
|
||||
local pr_num="$1" session_id="$2" worktree="$3"
|
||||
local max_ci_fixes="${4:-3}" max_review_rounds="${5:-5}"
|
||||
local ci_fix_count=0 ci_retry_count=0 review_round=0
|
||||
local rc=0 remote="${FORGE_REMOTE:-origin}"
|
||||
|
||||
_PR_WALK_EXIT_REASON=""
|
||||
_prl_log "walking PR #${pr_num} to merge (max CI: ${max_ci_fixes}, max review: ${max_review_rounds})"
|
||||
|
||||
while true; do
|
||||
# ── Poll CI ────────────────────────────────────────────────────────
|
||||
rc=0; pr_poll_ci "$pr_num" || rc=$?
|
||||
|
||||
if [ "$rc" -eq 2 ]; then
|
||||
_PR_WALK_EXIT_REASON="ci_timeout"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$rc" -eq 1 ]; then
|
||||
# Infra failure — retry once via empty commit + push
|
||||
if [ "${_PR_CI_FAILURE_TYPE:-}" = "infra" ] && [ "$ci_retry_count" -lt 1 ]; then
|
||||
ci_retry_count=$((ci_retry_count + 1))
|
||||
_prl_log "infra failure — retriggering CI (retry ${ci_retry_count})"
|
||||
local rebase_output rebase_rc
|
||||
( cd "$worktree" && \
|
||||
git commit --allow-empty -m "ci: retrigger after infra failure" --no-verify && \
|
||||
git fetch "$remote" "${PRIMARY_BRANCH}" 2>/dev/null && \
|
||||
git rebase "${remote}/${PRIMARY_BRANCH}" && \
|
||||
git push --force-with-lease "$remote" HEAD ) > /tmp/rebase-output-$$ 2>&1
|
||||
rebase_rc=$?
|
||||
rebase_output=$(cat /tmp/rebase-output-$$)
|
||||
rm -f /tmp/rebase-output-$$
|
||||
if [ "$rebase_rc" -ne 0 ]; then
|
||||
_prl_log "rebase/push failed (exit code $rebase_rc): $(echo "$rebase_output" | tail -5)"
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
ci_fix_count=$((ci_fix_count + 1))
|
||||
if [ "$ci_fix_count" -gt "$max_ci_fixes" ]; then
|
||||
_prl_log "CI fix budget exhausted (${ci_fix_count}/${max_ci_fixes})"
|
||||
_PR_WALK_EXIT_REASON="ci_exhausted"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_prl_log "CI failed — invoking agent (attempt ${ci_fix_count}/${max_ci_fixes})"
|
||||
|
||||
# Get CI logs from SQLite database if available
|
||||
local ci_logs=""
|
||||
if [ -n "$_PR_CI_PIPELINE" ] && [ -n "${FACTORY_ROOT:-}" ]; then
|
||||
ci_logs=$(ci_get_logs "$_PR_CI_PIPELINE" 2>/dev/null | tail -50) || ci_logs=""
|
||||
fi
|
||||
|
||||
local logs_section=""
|
||||
if [ -n "$ci_logs" ]; then
|
||||
logs_section="
|
||||
CI Log Output (last 50 lines):
|
||||
\`\`\`
|
||||
${ci_logs}
|
||||
\`\`\`
|
||||
"
|
||||
fi
|
||||
|
||||
agent_run --resume "$session_id" --worktree "$worktree" \
|
||||
"CI failed on PR #${pr_num} (attempt ${ci_fix_count}/${max_ci_fixes}).
|
||||
|
||||
Pipeline: #${_PR_CI_PIPELINE:-?}
|
||||
Failure type: ${_PR_CI_FAILURE_TYPE:-unknown}
|
||||
|
||||
Error log:
|
||||
${_PR_CI_ERROR_LOG:-No logs available.}${logs_section}
|
||||
|
||||
Fix the issue, run tests, commit, rebase on ${PRIMARY_BRANCH}, and push:
|
||||
git fetch ${remote} ${PRIMARY_BRANCH} && git rebase ${remote}/${PRIMARY_BRANCH}
|
||||
git push --force-with-lease ${remote} HEAD" || true
|
||||
continue
|
||||
fi
|
||||
|
||||
# CI passed — reset fix budget
|
||||
ci_fix_count=0
|
||||
|
||||
# ── Poll review ──────────────────────────────────────────────────────
|
||||
rc=0; pr_poll_review "$pr_num" || rc=$?
|
||||
|
||||
if [ "$rc" -eq 1 ]; then
|
||||
_PR_WALK_EXIT_REASON="review_timeout"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$rc" -eq 2 ]; then
|
||||
if [ "$_PR_REVIEW_VERDICT" = "MERGED_EXTERNALLY" ]; then
|
||||
_PR_WALK_EXIT_REASON="merged"
|
||||
return 0
|
||||
fi
|
||||
_PR_WALK_EXIT_REASON="closed_externally"
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "$_PR_REVIEW_VERDICT" in
|
||||
APPROVE)
|
||||
# ── Merge ──────────────────────────────────────────────────────
|
||||
rc=0; pr_merge "$pr_num" || rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
_PR_WALK_EXIT_REASON="merged"
|
||||
return 0
|
||||
fi
|
||||
# Merge failed (conflict or HTTP 405) — ask agent to rebase
|
||||
_prl_log "merge failed — invoking agent to rebase"
|
||||
agent_run --resume "$session_id" --worktree "$worktree" \
|
||||
"PR #${pr_num} approved but merge failed: ${_PR_MERGE_ERROR:-unknown}
|
||||
|
||||
Rebase onto ${PRIMARY_BRANCH} and push:
|
||||
git fetch ${remote} ${PRIMARY_BRANCH} && git rebase ${remote}/${PRIMARY_BRANCH}
|
||||
git push --force-with-lease ${remote} HEAD" || true
|
||||
continue
|
||||
;;
|
||||
|
||||
REQUEST_CHANGES|DISCUSS)
|
||||
review_round=$((review_round + 1))
|
||||
if [ "$review_round" -gt "$max_review_rounds" ]; then
|
||||
_prl_log "review budget exhausted (${review_round}/${max_review_rounds})"
|
||||
_PR_WALK_EXIT_REASON="review_exhausted"
|
||||
return 1
|
||||
fi
|
||||
ci_fix_count=0 # Reset CI fix budget per review cycle
|
||||
|
||||
_prl_log "review changes requested (round ${review_round}/${max_review_rounds})"
|
||||
agent_run --resume "$session_id" --worktree "$worktree" \
|
||||
"Review feedback (round ${review_round}/${max_review_rounds}) on PR #${pr_num}:
|
||||
|
||||
${_PR_REVIEW_TEXT:-No review text available.}
|
||||
|
||||
Address each piece of feedback. Run lint and tests.
|
||||
Commit, rebase on ${PRIMARY_BRANCH}, and push:
|
||||
git fetch ${remote} ${PRIMARY_BRANCH} && git rebase ${remote}/${PRIMARY_BRANCH}
|
||||
git push --force-with-lease ${remote} HEAD" || true
|
||||
continue
|
||||
;;
|
||||
|
||||
*)
|
||||
_prl_log "unexpected verdict: ${_PR_REVIEW_VERDICT:-empty}"
|
||||
_PR_WALK_EXIT_REASON="unexpected_verdict"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_phase_protocol_prompt — Generate push/commit instructions for Claude.
|
||||
#
|
||||
# For the synchronous agent_run architecture: tells Claude how to commit and
|
||||
# push (no phase files).
|
||||
#
|
||||
# Args: branch [remote]
|
||||
# Stdout: instruction text
|
||||
# ---------------------------------------------------------------------------
|
||||
build_phase_protocol_prompt() {
|
||||
local branch="$1" remote="${2:-${FORGE_REMOTE:-origin}}"
|
||||
cat <<_PRL_PROMPT_EOF_
|
||||
## Git workflow
|
||||
|
||||
After implementing changes:
|
||||
1. Stage and commit with a descriptive message.
|
||||
2. Rebase on the target branch before pushing:
|
||||
git fetch ${remote} ${PRIMARY_BRANCH} && git rebase ${remote}/${PRIMARY_BRANCH}
|
||||
3. Push your branch:
|
||||
git push ${remote} ${branch}
|
||||
If rejected, use: git push --force-with-lease ${remote} ${branch}
|
||||
|
||||
If you encounter rebase conflicts:
|
||||
1. Resolve conflicts in the affected files.
|
||||
2. Stage resolved files: git add <files>
|
||||
3. Continue rebase: git rebase --continue
|
||||
4. Push with --force-with-lease.
|
||||
_PRL_PROMPT_EOF_
|
||||
}
|
||||
178
lib/release.sh
178
lib/release.sh
|
|
@ -1,178 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# release.sh — disinto_release() function
|
||||
#
|
||||
# Handles vault TOML creation, branch setup on ops repo, PR creation,
|
||||
# and auto-merge request for a versioned release.
|
||||
#
|
||||
# Globals expected:
|
||||
# FORGE_URL - Forge instance URL (e.g. http://localhost:3000)
|
||||
# FORGE_TOKEN - API token for Forge operations
|
||||
# FORGE_OPS_REPO - Ops repo slug (e.g. disinto-admin/myproject-ops)
|
||||
# FACTORY_ROOT - Root of the disinto factory
|
||||
# PRIMARY_BRANCH - Primary branch name (e.g. main)
|
||||
#
|
||||
# Usage:
|
||||
# source "${FACTORY_ROOT}/lib/release.sh"
|
||||
# disinto_release <version>
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# Source vault.sh for _vault_log helper
|
||||
source "${FACTORY_ROOT}/lib/vault.sh"
|
||||
|
||||
# Assert required globals are set before using this module.
|
||||
_assert_release_globals() {
|
||||
local missing=()
|
||||
[ -z "${FORGE_URL:-}" ] && missing+=("FORGE_URL")
|
||||
[ -z "${FORGE_TOKEN:-}" ] && missing+=("FORGE_TOKEN")
|
||||
[ -z "${FORGE_OPS_REPO:-}" ] && missing+=("FORGE_OPS_REPO")
|
||||
[ -z "${FACTORY_ROOT:-}" ] && missing+=("FACTORY_ROOT")
|
||||
[ -z "${PRIMARY_BRANCH:-}" ] && missing+=("PRIMARY_BRANCH")
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
echo "Error: release.sh requires these globals to be set: ${missing[*]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
disinto_release() {
|
||||
_assert_release_globals
|
||||
|
||||
local version="${1:-}"
|
||||
local formula_path="${FACTORY_ROOT}/formulas/release.toml"
|
||||
|
||||
if [ -z "$version" ]; then
|
||||
echo "Error: version required" >&2
|
||||
echo "Usage: disinto release <version>" >&2
|
||||
echo "Example: disinto release v1.2.0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate version format (must start with 'v' followed by semver)
|
||||
if ! echo "$version" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "Error: version must be in format v1.2.3 (semver with 'v' prefix)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load project config to get FORGE_OPS_REPO
|
||||
if [ -z "${PROJECT_NAME:-}" ]; then
|
||||
# PROJECT_NAME is unset - detect project TOML from projects/ directory
|
||||
local found_toml
|
||||
found_toml=$(find "${FACTORY_ROOT}/projects" -maxdepth 1 -name "*.toml" ! -name "*.example" 2>/dev/null | head -1)
|
||||
if [ -n "$found_toml" ]; then
|
||||
source "${FACTORY_ROOT}/lib/load-project.sh" "$found_toml"
|
||||
fi
|
||||
else
|
||||
local project_toml="${FACTORY_ROOT}/projects/${PROJECT_NAME}.toml"
|
||||
if [ -f "$project_toml" ]; then
|
||||
source "${FACTORY_ROOT}/lib/load-project.sh" "$project_toml"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check formula exists
|
||||
if [ ! -f "$formula_path" ]; then
|
||||
echo "Error: release formula not found at ${formula_path}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get the ops repo root
|
||||
local ops_root="${FACTORY_ROOT}/../disinto-ops"
|
||||
if [ ! -d "${ops_root}/.git" ]; then
|
||||
echo "Error: ops repo not found at ${ops_root}" >&2
|
||||
echo " Run 'disinto init' to set up the ops repo first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Generate a unique ID for the vault item
|
||||
local id="release-${version//./}"
|
||||
local vault_toml="${ops_root}/vault/actions/${id}.toml"
|
||||
|
||||
# Create vault TOML with the specific version
|
||||
cat > "$vault_toml" <<EOF
|
||||
# vault/actions/${id}.toml
|
||||
# Release vault item for ${version}
|
||||
# Auto-generated by disinto release
|
||||
|
||||
id = "${id}"
|
||||
formula = "release"
|
||||
context = "Release ${version}"
|
||||
secrets = []
|
||||
EOF
|
||||
|
||||
echo "Created vault item: ${vault_toml}"
|
||||
|
||||
# Create a PR to submit the vault item to the ops repo
|
||||
local branch_name="release/${version//./}"
|
||||
local pr_title="release: ${version}"
|
||||
local pr_body="Release ${version}
|
||||
|
||||
This PR creates a vault item for the release of version ${version}.
|
||||
|
||||
## Changes
|
||||
- Added vault item: ${id}.toml
|
||||
|
||||
## Next Steps
|
||||
1. Review this PR
|
||||
2. Approve and merge
|
||||
3. The vault runner will execute the release formula
|
||||
"
|
||||
|
||||
# Create branch from clean primary branch
|
||||
(
|
||||
cd "$ops_root"
|
||||
git checkout "$PRIMARY_BRANCH"
|
||||
git pull origin "$PRIMARY_BRANCH"
|
||||
git checkout -B "$branch_name" "$PRIMARY_BRANCH"
|
||||
|
||||
# Add and commit only the vault TOML file
|
||||
git add "vault/actions/${id}.toml"
|
||||
git commit -m "$pr_title" -m "$pr_body" 2>/dev/null || true
|
||||
|
||||
# Push branch
|
||||
git push -u origin "$branch_name" 2>/dev/null || {
|
||||
echo "Error: failed to push branch" >&2
|
||||
exit 1
|
||||
}
|
||||
)
|
||||
|
||||
# Create PR
|
||||
local pr_response
|
||||
pr_response=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_URL}/api/v1/repos/${FORGE_OPS_REPO}/pulls" \
|
||||
-d "{\"title\":\"${pr_title}\",\"head\":\"${branch_name}\",\"base\":\"${PRIMARY_BRANCH}\",\"body\":\"$(echo "$pr_body" | sed ':a;N;$!ba;s/\n/\\n/g')\"}" 2>/dev/null) || {
|
||||
echo "Error: failed to create PR" >&2
|
||||
echo "Response: ${pr_response}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
local pr_number
|
||||
pr_number=$(echo "$pr_response" | jq -r '.number')
|
||||
|
||||
local pr_url="${FORGE_URL}/${FORGE_OPS_REPO}/pulls/${pr_number}"
|
||||
|
||||
# Enable auto-merge on the PR — Forgejo will auto-merge after approval
|
||||
_vault_log "Enabling auto-merge for PR #${pr_number}"
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_URL}/api/v1/repos/${FORGE_OPS_REPO}/pulls/${pr_number}/merge" \
|
||||
-d '{"Do":"merge","merge_when_checks_succeed":true}' >/dev/null 2>&1 || {
|
||||
echo "Warning: failed to enable auto-merge (may already be enabled or not supported)" >&2
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "Release PR created: ${pr_url}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Review the PR"
|
||||
echo " 2. Approve the PR (auto-merge will trigger after approval)"
|
||||
echo " 3. The vault runner will execute the release formula"
|
||||
echo ""
|
||||
echo "After merge, the release will:"
|
||||
echo " 1. Tag Forgejo main with ${version}"
|
||||
echo " 2. Push tag to mirrors (Codeberg, GitHub)"
|
||||
echo " 3. Build and tag the agents Docker image"
|
||||
echo " 4. Restart agent containers"
|
||||
}
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# stack-lock.sh — File-based lock protocol for singleton project stack access
|
||||
#
|
||||
# Prevents CI pipelines and the reproduce-agent from stepping on each other
|
||||
# when sharing a single project stack (e.g. harb docker compose).
|
||||
#
|
||||
# Lock file: /home/agent/data/locks/<project>-stack.lock
|
||||
# Contents: {"holder": "reproduce-agent-42", "since": "...", "heartbeat": "..."}
|
||||
#
|
||||
# Protocol:
|
||||
# 1. stack_lock_check — inspect current lock state
|
||||
# 2. stack_lock_acquire — wait until lock is free, then claim it
|
||||
# 3. stack_lock_release — delete lock file when done
|
||||
#
|
||||
# Heartbeat: callers must update the heartbeat every 2 minutes while holding
|
||||
# the lock by calling stack_lock_heartbeat. A heartbeat older than 10 minutes
|
||||
# is considered stale — the next acquire will break it.
|
||||
#
|
||||
# Usage:
|
||||
# source "$(dirname "$0")/../lib/stack-lock.sh"
|
||||
# stack_lock_acquire "ci-pipeline-$BUILD_NUMBER" "myproject"
|
||||
# trap 'stack_lock_release "myproject"' EXIT
|
||||
# # ... do work ...
|
||||
# stack_lock_release "myproject"
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
STACK_LOCK_DIR="${HOME}/data/locks"
|
||||
STACK_LOCK_POLL_INTERVAL=30 # seconds between retry polls
|
||||
STACK_LOCK_STALE_SECONDS=600 # 10 minutes — heartbeat older than this = stale
|
||||
STACK_LOCK_MAX_WAIT=3600 # 1 hour — give up after this many seconds
|
||||
|
||||
# _stack_lock_path <project>
|
||||
# Print the path of the lock file for the given project.
|
||||
_stack_lock_path() {
|
||||
local project="$1"
|
||||
echo "${STACK_LOCK_DIR}/${project}-stack.lock"
|
||||
}
|
||||
|
||||
# _stack_lock_now
|
||||
# Print current UTC timestamp in ISO-8601 format.
|
||||
_stack_lock_now() {
|
||||
date -u +"%Y-%m-%dT%H:%M:%SZ"
|
||||
}
|
||||
|
||||
# _stack_lock_epoch <iso_timestamp>
|
||||
# Convert an ISO-8601 UTC timestamp to a Unix epoch integer.
|
||||
_stack_lock_epoch() {
|
||||
local ts="$1"
|
||||
# Strip trailing Z, replace T with space for `date -d`
|
||||
date -u -d "${ts%Z}" +%s 2>/dev/null || date -u -j -f "%Y-%m-%dT%H:%M:%S" "${ts%Z}" +%s 2>/dev/null
|
||||
}
|
||||
|
||||
# stack_lock_check <project>
|
||||
# Print lock status to stdout: "free", "held:<holder>", or "stale:<holder>".
|
||||
# Returns 0 in all cases (status is in stdout).
|
||||
stack_lock_check() {
|
||||
local project="$1"
|
||||
local lock_file
|
||||
lock_file="$(_stack_lock_path "$project")"
|
||||
|
||||
if [ ! -f "$lock_file" ]; then
|
||||
echo "free"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local holder heartbeat
|
||||
holder=$(python3 -c 'import sys,json; d=json.load(open(sys.argv[1])); print(d.get("holder","unknown"))' "$lock_file" 2>/dev/null || echo "unknown")
|
||||
heartbeat=$(python3 -c 'import sys,json; d=json.load(open(sys.argv[1])); print(d.get("heartbeat",""))' "$lock_file" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$heartbeat" ]; then
|
||||
echo "stale:${holder}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local hb_epoch now_epoch age
|
||||
hb_epoch=$(_stack_lock_epoch "$heartbeat" 2>/dev/null || echo "0")
|
||||
now_epoch=$(date -u +%s)
|
||||
age=$(( now_epoch - hb_epoch ))
|
||||
|
||||
if [ "$age" -gt "$STACK_LOCK_STALE_SECONDS" ]; then
|
||||
echo "stale:${holder}"
|
||||
else
|
||||
echo "held:${holder}"
|
||||
fi
|
||||
}
|
||||
|
||||
# stack_lock_acquire <holder_id> <project> [max_wait_seconds]
|
||||
# Acquire the lock for <project> on behalf of <holder_id>.
|
||||
# Polls every STACK_LOCK_POLL_INTERVAL seconds.
|
||||
# Breaks stale locks automatically.
|
||||
# Exits non-zero if the lock cannot be acquired within max_wait_seconds.
|
||||
stack_lock_acquire() {
|
||||
local holder="$1"
|
||||
local project="$2"
|
||||
local max_wait="${3:-$STACK_LOCK_MAX_WAIT}"
|
||||
local lock_file
|
||||
lock_file="$(_stack_lock_path "$project")"
|
||||
local deadline
|
||||
deadline=$(( $(date -u +%s) + max_wait ))
|
||||
|
||||
mkdir -p "$STACK_LOCK_DIR"
|
||||
|
||||
while true; do
|
||||
local status
|
||||
status=$(stack_lock_check "$project")
|
||||
|
||||
case "$status" in
|
||||
free)
|
||||
# Write to temp file then rename to avoid partial reads by other processes
|
||||
local tmp_lock
|
||||
tmp_lock=$(mktemp "${STACK_LOCK_DIR}/.lock-tmp-XXXXXX")
|
||||
local now
|
||||
now=$(_stack_lock_now)
|
||||
printf '{"holder": "%s", "since": "%s", "heartbeat": "%s"}\n' \
|
||||
"$holder" "$now" "$now" > "$tmp_lock"
|
||||
mv "$tmp_lock" "$lock_file"
|
||||
echo "[stack-lock] acquired lock for ${project} as ${holder}" >&2
|
||||
return 0
|
||||
;;
|
||||
stale:*)
|
||||
local stale_holder="${status#stale:}"
|
||||
echo "[stack-lock] breaking stale lock held by ${stale_holder} for ${project}" >&2
|
||||
rm -f "$lock_file"
|
||||
# Loop back immediately to re-check and claim
|
||||
;;
|
||||
held:*)
|
||||
local cur_holder="${status#held:}"
|
||||
local remaining
|
||||
remaining=$(( deadline - $(date -u +%s) ))
|
||||
if [ "$remaining" -le 0 ]; then
|
||||
echo "[stack-lock] timed out waiting for lock on ${project} (held by ${cur_holder})" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "[stack-lock] ${project} locked by ${cur_holder}, waiting ${STACK_LOCK_POLL_INTERVAL}s (${remaining}s left)..." >&2
|
||||
sleep "$STACK_LOCK_POLL_INTERVAL"
|
||||
;;
|
||||
*)
|
||||
echo "[stack-lock] unexpected status '${status}' for ${project}" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# stack_lock_heartbeat <holder_id> <project>
|
||||
# Update the heartbeat timestamp in the lock file.
|
||||
# Should be called every 2 minutes while holding the lock.
|
||||
# No-op if the lock file is absent or held by a different holder.
|
||||
stack_lock_heartbeat() {
|
||||
local holder="$1"
|
||||
local project="$2"
|
||||
local lock_file
|
||||
lock_file="$(_stack_lock_path "$project")"
|
||||
|
||||
[ -f "$lock_file" ] || return 0
|
||||
|
||||
local current_holder
|
||||
current_holder=$(python3 -c 'import sys,json; d=json.load(open(sys.argv[1])); print(d.get("holder",""))' "$lock_file" 2>/dev/null || echo "")
|
||||
[ "$current_holder" = "$holder" ] || return 0
|
||||
|
||||
local since
|
||||
since=$(python3 -c 'import sys,json; d=json.load(open(sys.argv[1])); print(d.get("since",""))' "$lock_file" 2>/dev/null || echo "")
|
||||
local now
|
||||
now=$(_stack_lock_now)
|
||||
|
||||
local tmp_lock
|
||||
tmp_lock=$(mktemp "${STACK_LOCK_DIR}/.lock-tmp-XXXXXX")
|
||||
printf '{"holder": "%s", "since": "%s", "heartbeat": "%s"}\n' \
|
||||
"$holder" "$since" "$now" > "$tmp_lock"
|
||||
mv "$tmp_lock" "$lock_file"
|
||||
}
|
||||
|
||||
# stack_lock_release <project> [holder_id]
|
||||
# Release the lock for <project>.
|
||||
# If holder_id is provided, only releases if the lock is held by that holder
|
||||
# (prevents accidentally releasing someone else's lock).
|
||||
stack_lock_release() {
|
||||
local project="$1"
|
||||
local holder="${2:-}"
|
||||
local lock_file
|
||||
lock_file="$(_stack_lock_path "$project")"
|
||||
|
||||
[ -f "$lock_file" ] || return 0
|
||||
|
||||
if [ -n "$holder" ]; then
|
||||
local current_holder
|
||||
current_holder=$(python3 -c 'import sys,json; d=json.load(open(sys.argv[1])); print(d.get("holder",""))' "$lock_file" 2>/dev/null || echo "")
|
||||
if [ "$current_holder" != "$holder" ]; then
|
||||
echo "[stack-lock] refusing to release: lock held by '${current_holder}', not '${holder}'" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
rm -f "$lock_file"
|
||||
echo "[stack-lock] released lock for ${project}" >&2
|
||||
}
|
||||
232
lib/vault.sh
232
lib/vault.sh
|
|
@ -1,232 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# vault.sh — Helper for agents to create vault PRs on ops repo
|
||||
#
|
||||
# Source after lib/env.sh:
|
||||
# source "$(dirname "$0")/../lib/env.sh"
|
||||
# source "$(dirname "$0")/lib/vault.sh"
|
||||
#
|
||||
# Required globals: FORGE_TOKEN, FORGE_URL, FORGE_REPO, FORGE_OPS_REPO
|
||||
# Optional: OPS_REPO_ROOT (local path for ops repo)
|
||||
#
|
||||
# Functions:
|
||||
# vault_request <action_id> <toml_content> — Create vault PR, return PR number
|
||||
#
|
||||
# The function:
|
||||
# 1. Validates TOML content using validate_vault_action() from vault/vault-env.sh
|
||||
# 2. Creates a branch on the ops repo: vault/<action-id>
|
||||
# 3. Writes TOML to vault/actions/<action-id>.toml on that branch
|
||||
# 4. Creates PR targeting main with title "vault: <action-id>"
|
||||
# 5. Body includes context field from TOML
|
||||
# 6. Returns PR number (existing or newly created)
|
||||
#
|
||||
# Idempotent: if PR for same action-id exists, returns its number
|
||||
#
|
||||
# Uses Forgejo REST API (not git push) — works from containers without SSH
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Internal log helper
|
||||
_vault_log() {
|
||||
if declare -f log >/dev/null 2>&1; then
|
||||
log "vault: $*"
|
||||
else
|
||||
printf '[%s] vault: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# Get ops repo API URL
|
||||
_vault_ops_api() {
|
||||
printf '%s' "${FORGE_URL}/api/v1/repos/${FORGE_OPS_REPO}"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# vault_request — Create a vault PR or return existing one
|
||||
# Args: action_id toml_content
|
||||
# Stdout: PR number
|
||||
# Returns: 0=success, 1=validation failed, 2=API error
|
||||
# -----------------------------------------------------------------------------
|
||||
vault_request() {
|
||||
local action_id="$1"
|
||||
local toml_content="$2"
|
||||
|
||||
if [ -z "$action_id" ]; then
|
||||
echo "ERROR: action_id is required" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -z "$toml_content" ]; then
|
||||
echo "ERROR: toml_content is required" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if PR already exists for this action
|
||||
local existing_pr
|
||||
existing_pr=$(pr_find_by_branch "vault/${action_id}" "$(_vault_ops_api)") || true
|
||||
if [ -n "$existing_pr" ]; then
|
||||
_vault_log "PR already exists for action $action_id: #${existing_pr}"
|
||||
printf '%s' "$existing_pr"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Validate TOML content
|
||||
local tmp_toml
|
||||
tmp_toml=$(mktemp /tmp/vault-XXXXXX.toml)
|
||||
trap 'rm -f "$tmp_toml"' RETURN
|
||||
|
||||
printf '%s' "$toml_content" > "$tmp_toml"
|
||||
|
||||
# Source vault-env.sh for validate_vault_action
|
||||
local vault_env="${FACTORY_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/vault/vault-env.sh"
|
||||
if [ ! -f "$vault_env" ]; then
|
||||
echo "ERROR: vault-env.sh not found at $vault_env" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Save caller's FORGE_TOKEN, source vault-env.sh for validate_vault_action,
|
||||
# then restore caller's token so PR creation uses agent's identity (not vault-bot)
|
||||
local _saved_forge_token="${FORGE_TOKEN:-}"
|
||||
if ! source "$vault_env"; then
|
||||
FORGE_TOKEN="${_saved_forge_token:-}"
|
||||
echo "ERROR: failed to source vault-env.sh" >&2
|
||||
return 1
|
||||
fi
|
||||
# Restore caller's FORGE_TOKEN after validation
|
||||
FORGE_TOKEN="${_saved_forge_token:-}"
|
||||
|
||||
# Run validation
|
||||
if ! validate_vault_action "$tmp_toml"; then
|
||||
echo "ERROR: TOML validation failed" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Extract values for PR creation
|
||||
local pr_title pr_body
|
||||
pr_title="vault: ${action_id}"
|
||||
pr_body="Vault action: ${action_id}
|
||||
|
||||
Context: ${VAULT_ACTION_CONTEXT:-No context provided}
|
||||
|
||||
Formula: ${VAULT_ACTION_FORMULA:-}
|
||||
Secrets: ${VAULT_ACTION_SECRETS:-}
|
||||
|
||||
---
|
||||
This vault action has been created by an agent and requires admin approval
|
||||
before execution. See the TOML file for details."
|
||||
|
||||
# Get ops repo API URL
|
||||
local ops_api
|
||||
ops_api="$(_vault_ops_api)"
|
||||
|
||||
# Create branch
|
||||
local branch="vault/${action_id}"
|
||||
local branch_exists
|
||||
|
||||
branch_exists=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${ops_api}/git/branches/${branch}" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$branch_exists" != "200" ]; then
|
||||
# Branch doesn't exist, create it from main
|
||||
_vault_log "Creating branch ${branch} on ops repo"
|
||||
|
||||
# Get the commit SHA of main branch
|
||||
local main_sha
|
||||
main_sha=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${ops_api}/git/branches/${PRIMARY_BRANCH:-main}" 2>/dev/null | \
|
||||
jq -r '.commit.id // empty' || true)
|
||||
|
||||
if [ -z "$main_sha" ]; then
|
||||
# Fallback: get from refs
|
||||
main_sha=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${ops_api}/git/refs/heads/${PRIMARY_BRANCH:-main}" 2>/dev/null | \
|
||||
jq -r '.object.sha // empty' || true)
|
||||
fi
|
||||
|
||||
if [ -z "$main_sha" ]; then
|
||||
echo "ERROR: could not get main branch SHA" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Create the branch
|
||||
if ! curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${ops_api}/git/branches" \
|
||||
-d "{\"ref\":\"${branch}\",\"sha\":\"${main_sha}\"}" >/dev/null 2>&1; then
|
||||
echo "ERROR: failed to create branch ${branch}" >&2
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
_vault_log "Branch ${branch} already exists"
|
||||
fi
|
||||
|
||||
# Write TOML file to branch via API
|
||||
local file_path="vault/actions/${action_id}.toml"
|
||||
_vault_log "Writing ${file_path} to branch ${branch}"
|
||||
|
||||
# Encode TOML content as base64
|
||||
local encoded_content
|
||||
encoded_content=$(printf '%s' "$toml_content" | base64 -w 0)
|
||||
|
||||
# Upload file using Forgejo content API
|
||||
if ! curl -sf -X PUT \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${ops_api}/contents/${file_path}" \
|
||||
-d "{\"message\":\"vault: add ${action_id}\",\"branch\":\"${branch}\",\"content\":\"${encoded_content}\",\"committer\":{\"name\":\"vault-bot\",\"email\":\"vault-bot@${FORGE_REPO}\"},\"overwrite\":true}" >/dev/null 2>&1; then
|
||||
echo "ERROR: failed to write ${file_path} to branch ${branch}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Create PR
|
||||
_vault_log "Creating PR for ${branch}"
|
||||
|
||||
local pr_num
|
||||
pr_num=$(pr_create "$branch" "$pr_title" "$pr_body" "$PRIMARY_BRANCH" "$ops_api") || {
|
||||
echo "ERROR: failed to create PR" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Enable auto-merge on the PR — Forgejo will auto-merge after approval
|
||||
_vault_log "Enabling auto-merge for PR #${pr_num}"
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${ops_api}/pulls/${pr_num}/merge" \
|
||||
-d '{"Do":"merge","merge_when_checks_succeed":true}' >/dev/null 2>&1 || {
|
||||
_vault_log "Warning: failed to enable auto-merge (may already be enabled or not supported)"
|
||||
}
|
||||
|
||||
# Add labels to PR (vault, pending-approval)
|
||||
_vault_log "PR #${pr_num} created, adding labels"
|
||||
|
||||
# Get label IDs
|
||||
local vault_label_id pending_label_id
|
||||
vault_label_id=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${ops_api}/labels" 2>/dev/null | \
|
||||
jq -r --arg n "vault" '.[] | select(.name == $n) | .id // empty' || true)
|
||||
|
||||
pending_label_id=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${ops_api}/labels" 2>/dev/null | \
|
||||
jq -r --arg n "pending-approval" '.[] | select(.name == $n) | .id // empty' || true)
|
||||
|
||||
# Add labels if they exist
|
||||
if [ -n "$vault_label_id" ]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${ops_api}/issues/${pr_num}/labels" \
|
||||
-d "[{\"id\":${vault_label_id}}]" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if [ -n "$pending_label_id" ]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${ops_api}/issues/${pr_num}/labels" \
|
||||
-d "[{\"id\":${pending_label_id}}]" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
printf '%s' "$pr_num"
|
||||
return 0
|
||||
}
|
||||
156
lib/worktree.sh
156
lib/worktree.sh
|
|
@ -1,156 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# worktree.sh — Reusable git worktree management for agents
|
||||
#
|
||||
# Functions:
|
||||
# worktree_create PATH BRANCH [BASE_REF] — create worktree, checkout base, fetch submodules
|
||||
# worktree_recover ISSUE_NUMBER PROJECT_NAME — detect existing PR/branch, reuse or recreate worktree
|
||||
# worktree_cleanup PATH — remove worktree + Claude Code project cache
|
||||
# worktree_cleanup_stale [MAX_AGE_HOURS] — prune orphaned /tmp worktrees older than threshold
|
||||
# worktree_preserve PATH REASON — mark worktree as preserved (skip cleanup on exit)
|
||||
#
|
||||
# Requires: lib/env.sh sourced (for FACTORY_ROOT, PROJECT_REPO_ROOT, log()).
|
||||
# Globals set by callers: FORGE_REMOTE (git remote name, default "origin").
|
||||
|
||||
# --- Internal: clear Claude Code project cache for a worktree path ---
|
||||
_worktree_clear_claude_cache() {
|
||||
local wt_path="$1"
|
||||
local claude_project_dir
|
||||
claude_project_dir="$HOME/.claude/projects/$(echo "$wt_path" | sed 's|/|-|g; s|^-||')"
|
||||
rm -rf "$claude_project_dir" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# worktree_create PATH BRANCH [BASE_REF]
|
||||
# Creates a git worktree at PATH on BRANCH, based on BASE_REF (default: FORGE_REMOTE/PRIMARY_BRANCH).
|
||||
# Fetches submodules after creation. Cleans up any stale worktree at PATH first.
|
||||
# Must be called from PROJECT_REPO_ROOT (or a repo directory).
|
||||
# Returns 0 on success, 1 on failure.
|
||||
worktree_create() {
|
||||
local wt_path="$1"
|
||||
local branch="$2"
|
||||
local base_ref="${3:-${FORGE_REMOTE:-origin}/${PRIMARY_BRANCH:-main}}"
|
||||
|
||||
# Clean up any prior worktree at this path
|
||||
worktree_cleanup "$wt_path"
|
||||
|
||||
if ! git worktree add "$wt_path" "$base_ref" -B "$branch" 2>&1; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
cd "$wt_path" || return 1
|
||||
git checkout -B "$branch" "$base_ref" 2>/dev/null || true
|
||||
git submodule update --init --recursive 2>/dev/null || true
|
||||
return 0
|
||||
}
|
||||
|
||||
# worktree_recover WORKTREE_PATH BRANCH FORGE_REMOTE
|
||||
# Detects an existing worktree at WORKTREE_PATH. If it exists and is on the
|
||||
# right BRANCH, reuses it (fast-forward pull). Otherwise, cleans and recreates.
|
||||
# Sets _WORKTREE_REUSED=true if the existing worktree was reused.
|
||||
# Must be called from PROJECT_REPO_ROOT (or a repo directory).
|
||||
# Returns 0 on success, 1 on failure.
|
||||
worktree_recover() {
|
||||
local wt_path="$1"
|
||||
local branch="$2"
|
||||
local remote="${3:-${FORGE_REMOTE:-origin}}"
|
||||
|
||||
_WORKTREE_REUSED=false
|
||||
|
||||
git fetch "$remote" "$branch" 2>/dev/null || true
|
||||
|
||||
# Reuse existing worktree if on the right branch
|
||||
if [ -d "$wt_path/.git" ] || [ -f "$wt_path/.git" ]; then
|
||||
local wt_branch
|
||||
wt_branch=$(cd "$wt_path" && git rev-parse --abbrev-ref HEAD 2>/dev/null) || true
|
||||
if [ "$wt_branch" = "$branch" ]; then
|
||||
cd "$wt_path" || return 1
|
||||
git pull --ff-only "$remote" "$branch" 2>/dev/null || git reset --hard "${remote}/${branch}" 2>/dev/null || true
|
||||
_WORKTREE_REUSED=true
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Clean and recreate
|
||||
worktree_cleanup "$wt_path"
|
||||
if ! git worktree add "$wt_path" "${remote}/${branch}" -B "$branch" 2>&1; then
|
||||
return 1
|
||||
fi
|
||||
cd "$wt_path" || return 1
|
||||
git submodule update --init --recursive 2>/dev/null || true
|
||||
return 0
|
||||
}
|
||||
|
||||
# worktree_cleanup PATH
|
||||
# Removes a git worktree and clears the Claude Code project cache for it.
|
||||
# Safe to call multiple times or on non-existent paths.
|
||||
# Requires: PROJECT_REPO_ROOT (falls back to current directory).
|
||||
worktree_cleanup() {
|
||||
local wt_path="$1"
|
||||
local repo_root="${PROJECT_REPO_ROOT:-$(pwd)}"
|
||||
cd "$repo_root" 2>/dev/null || true
|
||||
git worktree remove "$wt_path" --force 2>/dev/null || true
|
||||
rm -rf "$wt_path"
|
||||
_worktree_clear_claude_cache "$wt_path"
|
||||
}
|
||||
|
||||
# worktree_cleanup_stale [MAX_AGE_HOURS]
|
||||
# Scans /tmp for orphaned worktrees older than MAX_AGE_HOURS (default 24).
|
||||
# Skips worktrees that have active tmux panes or are marked as preserved.
|
||||
# Prunes dangling worktree references after cleanup.
|
||||
# Requires: PROJECT_REPO_ROOT.
|
||||
worktree_cleanup_stale() {
|
||||
local max_age_hours="${1:-24}"
|
||||
local max_age_seconds=$((max_age_hours * 3600))
|
||||
local now
|
||||
now=$(date +%s)
|
||||
local cleaned=0
|
||||
|
||||
# Collect active tmux pane working directories for safety check
|
||||
local active_dirs=""
|
||||
active_dirs=$(tmux list-panes -a -F '#{pane_current_path}' 2>/dev/null || true)
|
||||
|
||||
local wt_dir
|
||||
for wt_dir in /tmp/*-worktree-* /tmp/action-*-[0-9]* /tmp/disinto-*; do
|
||||
[ -d "$wt_dir" ] || continue
|
||||
# Must be a git worktree (has .git file or directory)
|
||||
[ -f "$wt_dir/.git" ] || [ -d "$wt_dir/.git" ] || continue
|
||||
|
||||
# Skip preserved worktrees
|
||||
[ -f "$wt_dir/.worktree-preserved" ] && continue
|
||||
|
||||
# Check age (use directory mtime)
|
||||
local dir_mtime
|
||||
dir_mtime=$(stat -c %Y "$wt_dir" 2>/dev/null || echo "$now")
|
||||
local age=$((now - dir_mtime))
|
||||
[ "$age" -lt "$max_age_seconds" ] && continue
|
||||
|
||||
# Skip if an active tmux pane is using this worktree
|
||||
if [ -n "$active_dirs" ] && echo "$active_dirs" | grep -qF "$wt_dir"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Remove the worktree and its Claude cache
|
||||
local repo_root="${PROJECT_REPO_ROOT:-$(pwd)}"
|
||||
git -C "$repo_root" worktree remove "$wt_dir" --force 2>/dev/null || rm -rf "$wt_dir"
|
||||
_worktree_clear_claude_cache "$wt_dir"
|
||||
log "cleaned stale worktree: ${wt_dir} (age: $((age / 3600))h)"
|
||||
cleaned=$((cleaned + 1))
|
||||
done
|
||||
|
||||
# Prune any dangling worktree references
|
||||
git -C "${PROJECT_REPO_ROOT:-$(pwd)}" worktree prune 2>/dev/null || true
|
||||
|
||||
[ "$cleaned" -gt 0 ] && log "cleaned ${cleaned} stale worktree(s)"
|
||||
}
|
||||
|
||||
# worktree_preserve PATH REASON
|
||||
# Marks a worktree as preserved for debugging. Preserved worktrees are skipped
|
||||
# by worktree_cleanup_stale. The reason is written to a marker file inside
|
||||
# the worktree directory.
|
||||
worktree_preserve() {
|
||||
local wt_path="$1"
|
||||
local reason="${2:-unspecified}"
|
||||
if [ -d "$wt_path" ]; then
|
||||
printf '%s\n' "$reason" > "$wt_path/.worktree-preserved"
|
||||
log "PRESERVED worktree for debugging: ${wt_path} (reason: ${reason})"
|
||||
fi
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
<!-- last-reviewed: ac2beac361503c8712ecfc72be0401b5968cce4e -->
|
||||
<!-- last-reviewed: 9b91c6a5bdd83513ca262dd468ea14db675971c1 -->
|
||||
# Planner Agent
|
||||
|
||||
**Role**: Strategic planning using a Prerequisite Tree (Theory of Constraints),
|
||||
|
|
@ -22,13 +22,12 @@ to detect issues ping-ponging between backlog and underspecified. Issues that
|
|||
need human decisions or external resources are filed as vault procurement items
|
||||
(`$OPS_REPO_ROOT/vault/pending/*.md`) instead of being escalated. Phase 3
|
||||
(file-at-constraints): identify the top 3 unresolved prerequisites that block
|
||||
the most downstream objectives — file issues using a **template-or-vision gate**:
|
||||
read issue templates from `.codeberg/ISSUE_TEMPLATE/*.yaml`, attempt to fill
|
||||
template fields (affected_files ≤3, acceptance_criteria ≤5, single clear approach),
|
||||
then apply complexity test: if work touches one subsystem with no design forks,
|
||||
file as `backlog` using matching template (bug/feature/refactor); otherwise
|
||||
label `vision` with problem statement and why it's vision-sized. **Human-blocked
|
||||
issues are routed through the vault** — the planner files an actionable procurement
|
||||
the most downstream objectives — file issues as either `backlog` (code changes,
|
||||
dev-agent) or `action` (run existing formula, action-agent). **Stuck issues
|
||||
(detected BOUNCED/LABEL_CHURN) are dispatched to the `groom-backlog` formula
|
||||
in breakdown mode instead of being re-promoted** — this breaks the ping-pong
|
||||
loop by splitting them into dev-agent-sized sub-issues. **Human-blocked issues
|
||||
are routed through the vault** — the planner files an actionable procurement
|
||||
item (`$OPS_REPO_ROOT/vault/pending/<project>-<slug>.md` with What/Why/Human action/Factory
|
||||
will then sections) and marks the prerequisite as blocked-on-vault in the tree.
|
||||
Deduplication: checks pending/ + approved/ + fired/ before creating.
|
||||
|
|
@ -57,15 +56,15 @@ component, not work.
|
|||
prediction-triage, update-prerequisite-tree, file-at-constraints,
|
||||
journal-and-memory, commit-and-pr) with `needs` dependencies. Claude
|
||||
executes all steps in a single interactive session with tool access
|
||||
- `formulas/groom-backlog.toml` — Grooming formula for backlog triage and
|
||||
grooming. (Note: the planner no longer dispatches breakdown mode — complex
|
||||
issues are labeled `vision` instead.)
|
||||
- `formulas/groom-backlog.toml` — Dual-mode formula: grooming (default) or
|
||||
breakdown (dispatched by planner for bounced/stuck issues — splits the issue
|
||||
into dev-agent-sized sub-issues, removes `underspecified` label)
|
||||
- `$OPS_REPO_ROOT/prerequisites.md` — Prerequisite tree: versioned constraint
|
||||
map linking VISION.md objectives to their prerequisites. Planner owns the
|
||||
tree, humans steer by editing VISION.md. Tree grows organically as the
|
||||
planner discovers new prerequisites during runs
|
||||
- `$OPS_REPO_ROOT/knowledge/planner-memory.md` — Persistent memory across runs (in ops repo)
|
||||
|
||||
- `$OPS_REPO_ROOT/journal/planner/*.md` — Daily raw logs from each planner run (in ops repo)
|
||||
|
||||
**Constraint focus**: The planner uses Theory of Constraints to avoid premature
|
||||
issue filing. Only the top 3 unresolved prerequisites that block the most
|
||||
|
|
|
|||
|
|
@ -1,16 +1,10 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# planner-run.sh — Cron wrapper: planner execution via SDK + formula
|
||||
# planner-run.sh — Cron wrapper: direct planner execution via Claude + formula
|
||||
#
|
||||
# Synchronous bash loop using claude -p (one-shot invocation).
|
||||
# No tmux sessions, no phase files — the bash script IS the state machine.
|
||||
#
|
||||
# Flow:
|
||||
# 1. Guards: cron lock, memory check
|
||||
# 2. Load formula (formulas/run-planner.toml)
|
||||
# 3. Context: VISION.md, AGENTS.md, ops:RESOURCES.md, structural graph,
|
||||
# planner memory, journal entries
|
||||
# 4. agent_run(worktree, prompt) → Claude plans, may push knowledge updates
|
||||
# Runs daily (or on-demand). Guards against concurrent runs and low memory.
|
||||
# Creates a tmux session with Claude (opus) reading formulas/run-planner.toml.
|
||||
# No action issues — the planner is a nervous system component, not work.
|
||||
#
|
||||
# Usage:
|
||||
# planner-run.sh [projects/disinto.toml] # project config (default: disinto)
|
||||
|
|
@ -26,49 +20,34 @@ export PROJECT_TOML="${1:-$FACTORY_ROOT/projects/disinto.toml}"
|
|||
source "$FACTORY_ROOT/lib/env.sh"
|
||||
# Use planner-bot's own Forgejo identity (#747)
|
||||
FORGE_TOKEN="${FORGE_PLANNER_TOKEN:-${FORGE_TOKEN}}"
|
||||
# shellcheck source=../lib/agent-session.sh
|
||||
source "$FACTORY_ROOT/lib/agent-session.sh"
|
||||
# shellcheck source=../lib/formula-session.sh
|
||||
source "$FACTORY_ROOT/lib/formula-session.sh"
|
||||
# shellcheck source=../lib/worktree.sh
|
||||
source "$FACTORY_ROOT/lib/worktree.sh"
|
||||
# shellcheck source=../lib/guard.sh
|
||||
source "$FACTORY_ROOT/lib/guard.sh"
|
||||
# shellcheck source=../lib/agent-sdk.sh
|
||||
source "$FACTORY_ROOT/lib/agent-sdk.sh"
|
||||
|
||||
LOG_FILE="${DISINTO_LOG_DIR}/planner/planner.log"
|
||||
# shellcheck disable=SC2034 # consumed by agent-sdk.sh
|
||||
LOGFILE="$LOG_FILE"
|
||||
# shellcheck disable=SC2034 # consumed by agent-sdk.sh
|
||||
SID_FILE="/tmp/planner-session-${PROJECT_NAME}.sid"
|
||||
LOG_FILE="$SCRIPT_DIR/planner.log"
|
||||
# shellcheck disable=SC2034 # consumed by run_formula_and_monitor
|
||||
SESSION_NAME="planner-${PROJECT_NAME}"
|
||||
PHASE_FILE="/tmp/planner-session-${PROJECT_NAME}.phase"
|
||||
|
||||
# shellcheck disable=SC2034 # read by monitor_phase_loop in lib/agent-session.sh
|
||||
PHASE_POLL_INTERVAL=15
|
||||
|
||||
SCRATCH_FILE="/tmp/planner-${PROJECT_NAME}-scratch.md"
|
||||
WORKTREE="/tmp/${PROJECT_NAME}-planner-run"
|
||||
|
||||
# Override LOG_AGENT for consistent agent identification
|
||||
# shellcheck disable=SC2034 # consumed by agent-sdk.sh and env.sh log()
|
||||
LOG_AGENT="planner"
|
||||
|
||||
# Override log() to append to planner-specific log file
|
||||
# shellcheck disable=SC2034
|
||||
log() {
|
||||
local agent="${LOG_AGENT:-planner}"
|
||||
printf '[%s] %s: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$agent" "$*" >> "$LOG_FILE"
|
||||
}
|
||||
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; }
|
||||
|
||||
# ── Guards ────────────────────────────────────────────────────────────────
|
||||
check_active planner
|
||||
acquire_cron_lock "/tmp/planner-run.lock"
|
||||
memory_guard 2000
|
||||
check_memory 2000
|
||||
|
||||
log "--- Planner run start ---"
|
||||
|
||||
# ── Resolve forge remote for git operations ─────────────────────────────
|
||||
resolve_forge_remote
|
||||
|
||||
# ── Resolve agent identity for .profile repo ────────────────────────────
|
||||
resolve_agent_identity || true
|
||||
|
||||
# ── Load formula + context ───────────────────────────────────────────────
|
||||
load_formula_or_profile "planner" "$FACTORY_ROOT/formulas/run-planner.toml" || exit 1
|
||||
load_formula "$FACTORY_ROOT/formulas/run-planner.toml"
|
||||
build_context_block VISION.md AGENTS.md ops:RESOURCES.md ops:prerequisites.md
|
||||
|
||||
# ── Build structural analysis graph ──────────────────────────────────────
|
||||
|
|
@ -87,24 +66,41 @@ $(cat "$MEMORY_FILE")
|
|||
"
|
||||
fi
|
||||
|
||||
# ── Prepare .profile context (lessons injection) ─────────────────────────
|
||||
formula_prepare_profile_context
|
||||
# ── Read recent journal files ──────────────────────────────────────────
|
||||
JOURNAL_BLOCK=""
|
||||
JOURNAL_DIR="$OPS_REPO_ROOT/journal/planner"
|
||||
if [ -d "$JOURNAL_DIR" ]; then
|
||||
# Load last 5 journal files (most recent first) for run history context
|
||||
JOURNAL_FILES=$(find "$JOURNAL_DIR" -name '*.md' -type f | sort -r | head -5)
|
||||
if [ -n "$JOURNAL_FILES" ]; then
|
||||
JOURNAL_BLOCK="
|
||||
### Recent journal entries (journal/planner/)
|
||||
"
|
||||
while IFS= read -r jf; do
|
||||
JOURNAL_BLOCK="${JOURNAL_BLOCK}
|
||||
#### $(basename "$jf")
|
||||
$(cat "$jf")
|
||||
"
|
||||
done <<< "$JOURNAL_FILES"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Read scratch file (compaction survival) ───────────────────────────────
|
||||
SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
|
||||
SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
|
||||
|
||||
# ── Build prompt ─────────────────────────────────────────────────────────
|
||||
build_sdk_prompt_footer "
|
||||
build_prompt_footer "
|
||||
Relabel: curl -sf -H \"Authorization: token \${FORGE_TOKEN}\" -X PUT -H 'Content-Type: application/json' '${FORGE_API}/issues/{number}/labels' -d '{\"labels\":[LABEL_ID]}'
|
||||
Comment: curl -sf -H \"Authorization: token \${FORGE_TOKEN}\" -X POST -H 'Content-Type: application/json' '${FORGE_API}/issues/{number}/comments' -d '{\"body\":\"...\"}'
|
||||
Close: curl -sf -H \"Authorization: token \${FORGE_TOKEN}\" -X PATCH -H 'Content-Type: application/json' '${FORGE_API}/issues/{number}' -d '{\"state\":\"closed\"}'
|
||||
"
|
||||
|
||||
PROMPT="You are the strategic planner for ${FORGE_REPO}. Work through the formula below.
|
||||
# shellcheck disable=SC2034 # consumed by run_formula_and_monitor
|
||||
PROMPT="You are the strategic planner for ${FORGE_REPO}. Work through the formula below. You MUST write PHASE:done to '${PHASE_FILE}' when finished — the orchestrator will time you out if you return to the prompt without signalling.
|
||||
|
||||
## Project context
|
||||
${CONTEXT_BLOCK}${MEMORY_BLOCK}$(formula_lessons_block)
|
||||
${CONTEXT_BLOCK}${MEMORY_BLOCK}${JOURNAL_BLOCK}
|
||||
${GRAPH_SECTION}
|
||||
${SCRATCH_CONTEXT:+${SCRATCH_CONTEXT}
|
||||
}
|
||||
|
|
@ -115,17 +111,12 @@ ${SCRATCH_INSTRUCTION}
|
|||
|
||||
${PROMPT_FOOTER}"
|
||||
|
||||
# ── Create worktree ──────────────────────────────────────────────────────
|
||||
formula_worktree_setup "$WORKTREE"
|
||||
|
||||
# ── Run agent ─────────────────────────────────────────────────────────────
|
||||
# ── Run session ──────────────────────────────────────────────────────────
|
||||
export CLAUDE_MODEL="opus"
|
||||
run_formula_and_monitor "planner"
|
||||
|
||||
agent_run --worktree "$WORKTREE" "$PROMPT"
|
||||
log "agent_run complete"
|
||||
|
||||
# Write journal entry post-session
|
||||
profile_write_journal "planner-run" "Planner run $(date -u +%Y-%m-%d)" "complete" "" || true
|
||||
|
||||
rm -f "$SCRATCH_FILE"
|
||||
log "--- Planner run done ---"
|
||||
# ── Cleanup scratch file on normal exit ──────────────────────────────────
|
||||
# FINAL_PHASE already set by run_formula_and_monitor
|
||||
if [ "${FINAL_PHASE:-}" = "PHASE:done" ]; then
|
||||
rm -f "$SCRATCH_FILE"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<!-- last-reviewed: ac2beac361503c8712ecfc72be0401b5968cce4e -->
|
||||
<!-- last-reviewed: 9b91c6a5bdd83513ca262dd468ea14db675971c1 -->
|
||||
# Predictor Agent
|
||||
|
||||
**Role**: Abstract adversary (the "goblin"). Runs a 2-step formula
|
||||
|
|
|
|||
|
|
@ -1,15 +1,10 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# predictor-run.sh — Cron wrapper: predictor execution via SDK + formula
|
||||
# predictor-run.sh — Cron wrapper: predictor execution via Claude + formula
|
||||
#
|
||||
# Synchronous bash loop using claude -p (one-shot invocation).
|
||||
# No tmux sessions, no phase files — the bash script IS the state machine.
|
||||
#
|
||||
# Flow:
|
||||
# 1. Guards: cron lock, memory check
|
||||
# 2. Load formula (formulas/run-predictor.toml)
|
||||
# 3. Context: AGENTS.md, ops:RESOURCES.md, VISION.md, structural graph
|
||||
# 4. agent_run(worktree, prompt) → Claude analyzes, writes to ops repo
|
||||
# Runs daily (or on-demand). Guards against concurrent runs and low memory.
|
||||
# Creates a tmux session with Claude (sonnet) reading formulas/run-predictor.toml.
|
||||
# Files prediction/unreviewed issues for the planner to triage.
|
||||
#
|
||||
# Usage:
|
||||
# predictor-run.sh [projects/disinto.toml] # project config (default: disinto)
|
||||
|
|
@ -27,66 +22,48 @@ export PROJECT_TOML="${1:-$FACTORY_ROOT/projects/disinto.toml}"
|
|||
source "$FACTORY_ROOT/lib/env.sh"
|
||||
# Use predictor-bot's own Forgejo identity (#747)
|
||||
FORGE_TOKEN="${FORGE_PREDICTOR_TOKEN:-${FORGE_TOKEN}}"
|
||||
# shellcheck source=../lib/agent-session.sh
|
||||
source "$FACTORY_ROOT/lib/agent-session.sh"
|
||||
# shellcheck source=../lib/formula-session.sh
|
||||
source "$FACTORY_ROOT/lib/formula-session.sh"
|
||||
# shellcheck source=../lib/worktree.sh
|
||||
source "$FACTORY_ROOT/lib/worktree.sh"
|
||||
# shellcheck source=../lib/guard.sh
|
||||
source "$FACTORY_ROOT/lib/guard.sh"
|
||||
# shellcheck source=../lib/agent-sdk.sh
|
||||
source "$FACTORY_ROOT/lib/agent-sdk.sh"
|
||||
|
||||
LOG_FILE="${DISINTO_LOG_DIR}/predictor/predictor.log"
|
||||
# shellcheck disable=SC2034 # consumed by agent-sdk.sh
|
||||
LOGFILE="$LOG_FILE"
|
||||
# shellcheck disable=SC2034 # consumed by agent-sdk.sh
|
||||
SID_FILE="/tmp/predictor-session-${PROJECT_NAME}.sid"
|
||||
LOG_FILE="$SCRIPT_DIR/predictor.log"
|
||||
# shellcheck disable=SC2034 # consumed by run_formula_and_monitor
|
||||
SESSION_NAME="predictor-${PROJECT_NAME}"
|
||||
PHASE_FILE="/tmp/predictor-session-${PROJECT_NAME}.phase"
|
||||
|
||||
# shellcheck disable=SC2034 # read by monitor_phase_loop in lib/agent-session.sh
|
||||
PHASE_POLL_INTERVAL=15
|
||||
|
||||
SCRATCH_FILE="/tmp/predictor-${PROJECT_NAME}-scratch.md"
|
||||
WORKTREE="/tmp/${PROJECT_NAME}-predictor-run"
|
||||
|
||||
# Override LOG_AGENT for consistent agent identification
|
||||
# shellcheck disable=SC2034 # consumed by agent-sdk.sh and env.sh log()
|
||||
LOG_AGENT="predictor"
|
||||
|
||||
# Override log() to append to predictor-specific log file
|
||||
# shellcheck disable=SC2034
|
||||
log() {
|
||||
local agent="${LOG_AGENT:-predictor}"
|
||||
printf '[%s] %s: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$agent" "$*" >> "$LOG_FILE"
|
||||
}
|
||||
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; }
|
||||
|
||||
# ── Guards ────────────────────────────────────────────────────────────────
|
||||
check_active predictor
|
||||
acquire_cron_lock "/tmp/predictor-run.lock"
|
||||
memory_guard 2000
|
||||
check_memory 2000
|
||||
|
||||
log "--- Predictor run start ---"
|
||||
|
||||
# ── Resolve forge remote for git operations ─────────────────────────────
|
||||
resolve_forge_remote
|
||||
|
||||
# ── Resolve agent identity for .profile repo ────────────────────────────
|
||||
resolve_agent_identity || true
|
||||
|
||||
# ── Load formula + context ───────────────────────────────────────────────
|
||||
load_formula_or_profile "predictor" "$FACTORY_ROOT/formulas/run-predictor.toml" || exit 1
|
||||
load_formula "$FACTORY_ROOT/formulas/run-predictor.toml"
|
||||
build_context_block AGENTS.md ops:RESOURCES.md VISION.md ops:prerequisites.md
|
||||
|
||||
# ── Build structural analysis graph ──────────────────────────────────────
|
||||
build_graph_section
|
||||
|
||||
# ── Prepare .profile context (lessons injection) ─────────────────────────
|
||||
formula_prepare_profile_context
|
||||
|
||||
# ── Read scratch file (compaction survival) ───────────────────────────────
|
||||
SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
|
||||
SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
|
||||
|
||||
# ── Build prompt ─────────────────────────────────────────────────────────
|
||||
build_sdk_prompt_footer
|
||||
export CLAUDE_MODEL="sonnet"
|
||||
build_prompt_footer
|
||||
|
||||
PROMPT="You are the prediction agent (goblin) for ${FORGE_REPO}. Work through the formula below.
|
||||
# shellcheck disable=SC2034 # consumed by run_formula_and_monitor
|
||||
PROMPT="You are the prediction agent (goblin) for ${FORGE_REPO}. Work through the formula below. You MUST write PHASE:done to '${PHASE_FILE}' when finished — the orchestrator will time you out if you return to the prompt without signalling.
|
||||
|
||||
Your role: abstract adversary. Find the project's biggest weakness, challenge
|
||||
planner claims, and generate evidence. Explore when uncertain (file a prediction),
|
||||
|
|
@ -100,25 +77,21 @@ Use WebSearch for external signal scanning — be targeted (project dependencies
|
|||
and tools only, not general news). Limit to 3 web searches per run.
|
||||
|
||||
## Project context
|
||||
${CONTEXT_BLOCK}$(formula_lessons_block)
|
||||
${CONTEXT_BLOCK}
|
||||
${GRAPH_SECTION}
|
||||
${SCRATCH_CONTEXT:+${SCRATCH_CONTEXT}
|
||||
}
|
||||
${SCRATCH_CONTEXT}
|
||||
## Formula
|
||||
${FORMULA_CONTENT}
|
||||
|
||||
${SCRATCH_INSTRUCTION}
|
||||
${PROMPT_FOOTER}"
|
||||
|
||||
# ── Create worktree ──────────────────────────────────────────────────────
|
||||
formula_worktree_setup "$WORKTREE"
|
||||
# ── Run session ──────────────────────────────────────────────────────────
|
||||
export CLAUDE_MODEL="sonnet"
|
||||
run_formula_and_monitor "predictor"
|
||||
|
||||
# ── Run agent ─────────────────────────────────────────────────────────────
|
||||
agent_run --worktree "$WORKTREE" "$PROMPT"
|
||||
log "agent_run complete"
|
||||
|
||||
# Write journal entry post-session
|
||||
profile_write_journal "predictor-run" "Predictor run $(date -u +%Y-%m-%d)" "complete" "" || true
|
||||
|
||||
rm -f "$SCRATCH_FILE"
|
||||
log "--- Predictor run done ---"
|
||||
# ── Cleanup scratch file on normal exit ──────────────────────────────────
|
||||
# FINAL_PHASE already set by run_formula_and_monitor
|
||||
if [ "${FINAL_PHASE:-}" = "PHASE:done" ]; then
|
||||
rm -f "$SCRATCH_FILE"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
name = "disinto"
|
||||
repo = "johba/disinto"
|
||||
ops_repo = "disinto-admin/disinto-ops"
|
||||
ops_repo = "johba/disinto-ops"
|
||||
forge_url = "http://localhost:3000"
|
||||
repo_root = "/home/YOU/dark-factory"
|
||||
ops_repo_root = "/home/YOU/disinto-ops"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<!-- last-reviewed: ac2beac361503c8712ecfc72be0401b5968cce4e -->
|
||||
<!-- last-reviewed: 9b91c6a5bdd83513ca262dd468ea14db675971c1 -->
|
||||
# Review Agent
|
||||
|
||||
**Role**: AI-powered PR review — post structured findings and formal
|
||||
|
|
@ -9,8 +9,8 @@ whose CI has passed and that lack a review for the current HEAD SHA, then
|
|||
spawns `review-pr.sh <pr-number>`.
|
||||
|
||||
**Key files**:
|
||||
- `review/review-poll.sh` — Cron scheduler: finds unreviewed PRs with passing CI. Sources `lib/guard.sh` and calls `check_active reviewer` — skips if `$FACTORY_ROOT/state/.reviewer-active` is absent. **Circuit breaker**: counts existing `<!-- review-error: <sha> -->` comments; skips a PR if ≥3 consecutive errors for the same HEAD SHA (prevents flooding on repeated review failures).
|
||||
- `review/review-pr.sh` — Creates/reuses a tmux session (`review-{project}-{pr}`), injects PR diff, waits for Claude to write structured JSON output, posts markdown review + formal forge review, auto-creates follow-up issues for pre-existing tech debt. Calls `resolve_forge_remote()` at startup to determine the correct git remote name (avoids hardcoded 'origin'). Before starting the session, runs `lib/build-graph.py --changed-files <PR files>` and appends the JSON structural analysis (affected objectives, orphaned prerequisites, thin evidence) to the review prompt. Graph failures are non-fatal — review proceeds without it.
|
||||
- `review/review-poll.sh` — Cron scheduler: finds unreviewed PRs with passing CI. Sources `lib/guard.sh` and calls `check_active reviewer` — skips if `$FACTORY_ROOT/state/.reviewer-active` is absent. When injecting review into a dev session, first looks for a bot comment containing `<!-- reviewed: SHA -->`, then falls back to formal Forgejo PR reviews (state `APPROVED` or `REQUEST_CHANGES`) — ensures the dev-agent receives the verdict even when bot comments are absent.
|
||||
- `review/review-pr.sh` — Creates/reuses a tmux session (`review-{project}-{pr}`), injects PR diff, waits for Claude to write structured JSON output, posts markdown review + formal forge review, auto-creates follow-up issues for pre-existing tech debt. Before starting the session, runs `lib/build-graph.py --changed-files <PR files>` and appends the JSON structural analysis (affected objectives, orphaned prerequisites, thin evidence) to the review prompt. Graph failures are non-fatal — review proceeds without it.
|
||||
|
||||
**Environment variables consumed**:
|
||||
- `FORGE_TOKEN` — Dev-agent token (must not be the same account as FORGE_REVIEW_TOKEN)
|
||||
|
|
|
|||
|
|
@ -19,19 +19,12 @@ REPO_ROOT="${PROJECT_REPO_ROOT}"
|
|||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
API_BASE="${FORGE_API}"
|
||||
LOGFILE="${DISINTO_LOG_DIR}/review/review-poll.log"
|
||||
LOGFILE="$SCRIPT_DIR/review.log"
|
||||
MAX_REVIEWS=3
|
||||
REVIEW_IDLE_TIMEOUT=14400 # 4h: kill review session if idle
|
||||
|
||||
# Override LOG_AGENT for consistent agent identification
|
||||
# shellcheck disable=SC2034 # consumed by agent-sdk.sh and env.sh log()
|
||||
LOG_AGENT="review"
|
||||
|
||||
# Override log() to append to review-specific log file
|
||||
# shellcheck disable=SC2034
|
||||
log() {
|
||||
local agent="${LOG_AGENT:-review}"
|
||||
printf '[%s] %s: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$agent" "$*" >> "$LOGFILE"
|
||||
printf '[%s] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
|
||||
}
|
||||
|
||||
# Log rotation
|
||||
|
|
@ -45,42 +38,56 @@ fi
|
|||
|
||||
log "--- Poll start ---"
|
||||
|
||||
# --- Clean up stale review sessions (.sid files + worktrees) ---
|
||||
# Remove .sid files, phase files, and worktrees for merged/closed PRs or idle > 4h
|
||||
REVIEW_SIDS=$(compgen -G "/tmp/review-session-${PROJECT_NAME}-*.sid" 2>/dev/null || true)
|
||||
if [ -n "$REVIEW_SIDS" ]; then
|
||||
while IFS= read -r sid_file; do
|
||||
base=$(basename "$sid_file")
|
||||
pr_num="${base#review-session-"${PROJECT_NAME}"-}"
|
||||
pr_num="${pr_num%.sid}"
|
||||
# --- Clean up stale review sessions ---
|
||||
# Kill sessions for merged/closed PRs or idle > 4h
|
||||
REVIEW_SESSIONS=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep "^review-${PROJECT_NAME}-" || true)
|
||||
if [ -n "$REVIEW_SESSIONS" ]; then
|
||||
while IFS= read -r session; do
|
||||
pr_num="${session#review-"${PROJECT_NAME}"-}"
|
||||
phase_file="/tmp/review-session-${PROJECT_NAME}-${pr_num}.phase"
|
||||
worktree="/tmp/${PROJECT_NAME}-review-${pr_num}"
|
||||
|
||||
# Check if PR is still open
|
||||
pr_state=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${API_BASE}/pulls/${pr_num}" | jq -r '.state // "unknown"' 2>/dev/null) || true
|
||||
|
||||
if [ "$pr_state" != "open" ]; then
|
||||
log "cleanup: PR #${pr_num} state=${pr_state} — removing sid/worktree"
|
||||
rm -f "$sid_file" "$phase_file" "/tmp/${PROJECT_NAME}-review-output-${pr_num}.json"
|
||||
log "cleanup: killing session ${session} (PR #${pr_num} state=${pr_state})"
|
||||
tmux kill-session -t "$session" 2>/dev/null || true
|
||||
rm -f "$phase_file" "/tmp/${PROJECT_NAME}-review-output-${pr_num}.json" \
|
||||
"/tmp/review-injected-${PROJECT_NAME}-${pr_num}"
|
||||
cd "$REPO_ROOT"
|
||||
git worktree remove "$worktree" --force 2>/dev/null || true
|
||||
rm -rf "$worktree" 2>/dev/null || true
|
||||
git worktree remove "/tmp/${PROJECT_NAME}-review-${pr_num}" --force 2>/dev/null || true
|
||||
rm -rf "/tmp/${PROJECT_NAME}-review-${pr_num}" 2>/dev/null || true
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check idle timeout (4h) via .sid file mtime
|
||||
sid_mtime=$(stat -c %Y "$sid_file" 2>/dev/null || echo 0)
|
||||
# Check idle timeout (4h)
|
||||
phase_mtime=$(stat -c %Y "$phase_file" 2>/dev/null || echo 0)
|
||||
now=$(date +%s)
|
||||
if [ "$sid_mtime" -gt 0 ] && [ $(( now - sid_mtime )) -gt "$REVIEW_IDLE_TIMEOUT" ]; then
|
||||
log "cleanup: PR #${pr_num} idle > 4h — removing sid/worktree"
|
||||
rm -f "$sid_file" "$phase_file" "/tmp/${PROJECT_NAME}-review-output-${pr_num}.json"
|
||||
if [ "$phase_mtime" -gt 0 ] && [ $(( now - phase_mtime )) -gt "$REVIEW_IDLE_TIMEOUT" ]; then
|
||||
log "cleanup: killing session ${session} (idle > 4h)"
|
||||
tmux kill-session -t "$session" 2>/dev/null || true
|
||||
rm -f "$phase_file" "/tmp/${PROJECT_NAME}-review-output-${pr_num}.json" \
|
||||
"/tmp/review-injected-${PROJECT_NAME}-${pr_num}"
|
||||
cd "$REPO_ROOT"
|
||||
git worktree remove "$worktree" --force 2>/dev/null || true
|
||||
rm -rf "$worktree" 2>/dev/null || true
|
||||
git worktree remove "/tmp/${PROJECT_NAME}-review-${pr_num}" --force 2>/dev/null || true
|
||||
rm -rf "/tmp/${PROJECT_NAME}-review-${pr_num}" 2>/dev/null || true
|
||||
continue
|
||||
fi
|
||||
done <<< "$REVIEW_SIDS"
|
||||
|
||||
# Safety net: clean up sessions in terminal phases (review already posted)
|
||||
current_phase=$(head -1 "$phase_file" 2>/dev/null | tr -d '[:space:]' || true)
|
||||
if [ "$current_phase" = "PHASE:review_complete" ]; then
|
||||
log "cleanup: killing session ${session} (terminal phase: review_complete)"
|
||||
tmux kill-session -t "$session" 2>/dev/null || true
|
||||
rm -f "$phase_file" "/tmp/${PROJECT_NAME}-review-output-${pr_num}.json" \
|
||||
"/tmp/review-injected-${PROJECT_NAME}-${pr_num}"
|
||||
cd "$REPO_ROOT"
|
||||
git worktree remove "/tmp/${PROJECT_NAME}-review-${pr_num}" --force 2>/dev/null || true
|
||||
rm -rf "/tmp/${PROJECT_NAME}-review-${pr_num}" 2>/dev/null || true
|
||||
continue
|
||||
fi
|
||||
done <<< "$REVIEW_SESSIONS"
|
||||
fi
|
||||
|
||||
PRS=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
|
|
@ -98,12 +105,95 @@ log "Found ${TOTAL} open PRs"
|
|||
REVIEWED=0
|
||||
SKIPPED=0
|
||||
|
||||
# --- Re-review: trigger review for .sid files in awaiting_changes state with new commits ---
|
||||
if [ -n "$REVIEW_SIDS" ]; then
|
||||
while IFS= read -r sid_file; do
|
||||
base=$(basename "$sid_file")
|
||||
pr_num="${base#review-session-"${PROJECT_NAME}"-}"
|
||||
pr_num="${pr_num%.sid}"
|
||||
inject_review_into_dev_session() {
|
||||
local pr_num="$1" pr_sha="$2" pr_branch="$3"
|
||||
|
||||
local issue_num
|
||||
issue_num=$(printf '%s' "$pr_branch" | grep -oP 'issue-\K[0-9]+' || true)
|
||||
[ -z "$issue_num" ] && return 0
|
||||
|
||||
local session="dev-${PROJECT_NAME}-${issue_num}"
|
||||
local phase_file="/tmp/dev-session-${PROJECT_NAME}-${issue_num}.phase"
|
||||
|
||||
tmux has-session -t "${session}" 2>/dev/null || return 0
|
||||
|
||||
local current_phase
|
||||
current_phase=$(head -1 "${phase_file}" 2>/dev/null | tr -d '[:space:]' || true)
|
||||
[ "${current_phase}" = "PHASE:awaiting_review" ] || return 0
|
||||
|
||||
local review_text="" verdict=""
|
||||
|
||||
# Try bot review comment first (richer content with <!-- reviewed: SHA --> marker)
|
||||
local review_comment
|
||||
review_comment=$(forge_api_all "/issues/${pr_num}/comments" | \
|
||||
jq -r --arg sha "${pr_sha}" \
|
||||
'[.[] | select(.body | contains("<!-- reviewed: " + $sha))] | last // empty') || true
|
||||
if [ -n "${review_comment}" ] && [ "${review_comment}" != "null" ]; then
|
||||
review_text=$(printf '%s' "${review_comment}" | jq -r '.body')
|
||||
verdict=$(printf '%s' "${review_text}" | grep -oP '\*\*(APPROVE|REQUEST_CHANGES|DISCUSS)\*\*' | head -1 | tr -d '*' || true)
|
||||
fi
|
||||
|
||||
# Fallback: check formal forge reviews (#771)
|
||||
if [ -z "$verdict" ]; then
|
||||
local formal_review formal_state
|
||||
formal_review=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${API_BASE}/pulls/${pr_num}/reviews" | \
|
||||
jq -r '[.[] | select(.stale == false) | select(.state == "APPROVED" or .state == "REQUEST_CHANGES")] | last // empty') || true
|
||||
if [ -n "$formal_review" ] && [ "$formal_review" != "null" ]; then
|
||||
formal_state=$(printf '%s' "$formal_review" | jq -r '.state // ""')
|
||||
if [ "$formal_state" = "APPROVED" ]; then
|
||||
verdict="APPROVE"
|
||||
elif [ "$formal_state" = "REQUEST_CHANGES" ]; then
|
||||
verdict="REQUEST_CHANGES"
|
||||
fi
|
||||
[ -z "$review_text" ] && review_text=$(printf '%s' "$formal_review" | jq -r '.body // ""')
|
||||
fi
|
||||
fi
|
||||
|
||||
[ -z "$verdict" ] && return 0
|
||||
|
||||
local inject_msg=""
|
||||
if [ "${verdict}" = "APPROVE" ]; then
|
||||
inject_msg="Approved! PR #${pr_num} has been approved by the reviewer.
|
||||
|
||||
The orchestrator will handle merging and closing the issue automatically.
|
||||
You do not need to take any action — stop and wait."
|
||||
elif [ "${verdict}" = "REQUEST_CHANGES" ] || [ "${verdict}" = "DISCUSS" ]; then
|
||||
inject_msg="Review: ${verdict} on PR #${pr_num}:
|
||||
|
||||
${review_text}
|
||||
|
||||
Instructions:
|
||||
1. Address each piece of feedback carefully.
|
||||
2. Run lint and tests when done.
|
||||
3. Commit your changes and push: git push ${FORGE_REMOTE:-origin} ${pr_branch}
|
||||
4. Write: echo \"PHASE:awaiting_ci\" > \"${phase_file}\"
|
||||
5. Stop and wait for the next CI result."
|
||||
fi
|
||||
|
||||
[ -z "${inject_msg}" ] && return 0
|
||||
|
||||
local inject_tmp
|
||||
inject_tmp=$(mktemp /tmp/review-inject-XXXXXX)
|
||||
printf '%s' "${inject_msg}" > "${inject_tmp}"
|
||||
# All tmux calls guarded with || true: the dev session is external and may die
|
||||
# between the has-session check above and here; a non-zero exit must not abort
|
||||
# the outer poll loop under set -euo pipefail.
|
||||
tmux load-buffer -b "review-inject-${pr_num}" "${inject_tmp}" || true
|
||||
tmux paste-buffer -t "${session}" -b "review-inject-${pr_num}" || true
|
||||
sleep 0.5
|
||||
tmux send-keys -t "${session}" "" Enter || true
|
||||
tmux delete-buffer -b "review-inject-${pr_num}" 2>/dev/null || true
|
||||
rm -f "${inject_tmp}"
|
||||
log " #${pr_num} review (${verdict}) injected into session ${session}"
|
||||
# Write sentinel so dev-agent.sh awaiting_review loop skips its own injection
|
||||
touch "/tmp/review-injected-${PROJECT_NAME}-${pr_num}"
|
||||
}
|
||||
|
||||
# --- Re-review: trigger review for awaiting_changes sessions with new commits ---
|
||||
if [ -n "${REVIEW_SESSIONS:-}" ]; then
|
||||
while IFS= read -r session; do
|
||||
pr_num="${session#review-"${PROJECT_NAME}"-}"
|
||||
phase_file="/tmp/review-session-${PROJECT_NAME}-${pr_num}.phase"
|
||||
|
||||
current_phase=$(head -1 "$phase_file" 2>/dev/null | tr -d '[:space:]' || true)
|
||||
|
|
@ -120,6 +210,7 @@ if [ -n "$REVIEW_SIDS" ]; then
|
|||
[ "$pr_state" = "open" ] || continue
|
||||
|
||||
current_sha=$(printf '%s' "$pr_json" | jq -r '.head.sha // ""')
|
||||
pr_branch=$(printf '%s' "$pr_json" | jq -r '.head.ref // ""')
|
||||
if [ -z "$current_sha" ] || [ "$current_sha" = "$reviewed_sha" ]; then continue; fi
|
||||
|
||||
ci_state=$(ci_commit_status "$current_sha")
|
||||
|
|
@ -133,20 +224,23 @@ if [ -n "$REVIEW_SIDS" ]; then
|
|||
|
||||
log " #${pr_num} re-review: new commits (${reviewed_sha:0:7}→${current_sha:0:7})"
|
||||
|
||||
review_output=$("${SCRIPT_DIR}/review-pr.sh" "$pr_num" 2>&1) && review_rc=0 || review_rc=$?
|
||||
if [ "$review_rc" -eq 0 ]; then
|
||||
if "${SCRIPT_DIR}/review-pr.sh" "$pr_num" 2>&1; then
|
||||
REVIEWED=$((REVIEWED + 1))
|
||||
FRESH_SHA=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${API_BASE}/pulls/${pr_num}" | jq -r '.head.sha // ""') || true
|
||||
inject_review_into_dev_session "$pr_num" "${FRESH_SHA:-$current_sha}" "$pr_branch"
|
||||
else
|
||||
log " #${pr_num} re-review failed (exit code $review_rc): $(echo "$review_output" | tail -3)"
|
||||
log " #${pr_num} re-review failed"
|
||||
fi
|
||||
|
||||
[ "$REVIEWED" -lt "$MAX_REVIEWS" ] || break
|
||||
done <<< "$REVIEW_SIDS"
|
||||
done <<< "$REVIEW_SESSIONS"
|
||||
fi
|
||||
|
||||
while IFS= read -r line; do
|
||||
PR_NUM=$(echo "$line" | awk '{print $1}')
|
||||
PR_SHA=$(echo "$line" | awk '{print $2}')
|
||||
PR_BRANCH=$(echo "$line" | awk '{print $3}')
|
||||
|
||||
CI_STATE=$(ci_commit_status "$PR_SHA")
|
||||
|
||||
|
|
@ -168,31 +262,24 @@ while IFS= read -r line; do
|
|||
|
||||
if [ "${HAS_REVIEW:-0}" -gt "0" ]; then
|
||||
log " #${PR_NUM} formal review exists for ${PR_SHA:0:7}, skip"
|
||||
# Inject review feedback into dev session if awaiting (#771)
|
||||
inject_review_into_dev_session "$PR_NUM" "$PR_SHA" "$PR_BRANCH"
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
log " #${PR_NUM} needs review (CI=success, SHA=${PR_SHA:0:7})"
|
||||
|
||||
# Circuit breaker: count existing review-error comments for this SHA
|
||||
ERROR_COMMENTS=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${API_BASE}/issues/${PR_NUM}/comments" | \
|
||||
jq --arg sha "$PR_SHA" \
|
||||
'[.[] | select(.body | contains("<!-- review-error: " + $sha + " -->"))] | length')
|
||||
|
||||
if [ "${ERROR_COMMENTS:-0}" -ge 3 ]; then
|
||||
log " #${PR_NUM} blocked: ${ERROR_COMMENTS} consecutive error comments for ${PR_SHA:0:7}, skipping"
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
log " #${PR_NUM} error check: ${ERROR_COMMENTS:-0} prior error(s) for ${PR_SHA:0:7}"
|
||||
|
||||
review_output=$("${SCRIPT_DIR}/review-pr.sh" "$PR_NUM" 2>&1) && review_rc=0 || review_rc=$?
|
||||
if [ "$review_rc" -eq 0 ]; then
|
||||
if "${SCRIPT_DIR}/review-pr.sh" "$PR_NUM" 2>&1; then
|
||||
REVIEWED=$((REVIEWED + 1))
|
||||
# Re-fetch current SHA: review-pr.sh fetches the PR independently and tags its
|
||||
# comment with whatever SHA it saw. If a commit arrived while review-pr.sh was
|
||||
# running those two SHA captures diverge and we would miss the comment.
|
||||
FRESH_SHA=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${API_BASE}/pulls/${PR_NUM}" | jq -r '.head.sha // ""') || true
|
||||
inject_review_into_dev_session "$PR_NUM" "${FRESH_SHA:-$PR_SHA}" "$PR_BRANCH"
|
||||
else
|
||||
log " #${PR_NUM} review failed (exit code $review_rc): $(echo "$review_output" | tail -3)"
|
||||
log " #${PR_NUM} review failed"
|
||||
fi
|
||||
|
||||
if [ "$REVIEWED" -ge "$MAX_REVIEWS" ]; then
|
||||
|
|
|
|||
|
|
@ -1,91 +1,41 @@
|
|||
#!/usr/bin/env bash
|
||||
# shellcheck disable=SC2015,SC2016
|
||||
# review-pr.sh — Synchronous reviewer agent for a single PR
|
||||
#
|
||||
# review-pr.sh — Thin orchestrator for AI PR review (formula: formulas/review-pr.toml)
|
||||
# Usage: ./review-pr.sh <pr-number> [--force]
|
||||
#
|
||||
# Architecture:
|
||||
# Synchronous bash loop using claude -p (one-shot invocations).
|
||||
# Session continuity via --resume and .sid file.
|
||||
# Re-review resumes the original session — Claude remembers its prior review.
|
||||
#
|
||||
# Flow:
|
||||
# 1. Fetch PR metadata (title, body, head, base, SHA, CI state)
|
||||
# 2. Detect re-review (previous review at different SHA, incremental diff)
|
||||
# 3. Create review worktree, checkout PR head
|
||||
# 4. Build structural analysis graph
|
||||
# 5. Load review formula
|
||||
# 6. agent_run(worktree, prompt) → Claude reviews, writes verdict JSON
|
||||
# 7. Parse verdict, post as Forge review (APPROVE / REQUEST_CHANGES / COMMENT)
|
||||
# 8. Save session ID to .sid file for re-review continuity
|
||||
#
|
||||
# Session file: /tmp/review-session-{project}-{pr}.sid
|
||||
set -euo pipefail
|
||||
|
||||
# Load shared environment and libraries
|
||||
source "$(dirname "$0")/../lib/env.sh"
|
||||
source "$(dirname "$0")/../lib/ci-helpers.sh"
|
||||
source "$(dirname "$0")/../lib/worktree.sh"
|
||||
source "$(dirname "$0")/../lib/agent-sdk.sh"
|
||||
# shellcheck source=../lib/formula-session.sh
|
||||
source "$(dirname "$0")/../lib/formula-session.sh"
|
||||
|
||||
# Auto-pull factory code to pick up merged fixes before any logic runs
|
||||
source "$(dirname "$0")/../lib/agent-session.sh"
|
||||
git -C "$FACTORY_ROOT" pull --ff-only origin main 2>/dev/null || true
|
||||
|
||||
# --- Config ---
|
||||
PR_NUMBER="${1:?Usage: review-pr.sh <pr-number> [--force]}"
|
||||
FORCE="${2:-}"
|
||||
API="${FORGE_API}"
|
||||
LOGFILE="${DISINTO_LOG_DIR}/review/review.log"
|
||||
WORKTREE="/tmp/${PROJECT_NAME}-review-${PR_NUMBER}"
|
||||
SID_FILE="/tmp/review-session-${PROJECT_NAME}-${PR_NUMBER}.sid"
|
||||
LOGFILE="${FACTORY_ROOT}/review/review.log"
|
||||
SESSION="review-${PROJECT_NAME}-${PR_NUMBER}"
|
||||
PHASE_FILE="/tmp/review-session-${PROJECT_NAME}-${PR_NUMBER}.phase"
|
||||
OUTPUT_FILE="/tmp/${PROJECT_NAME}-review-output-${PR_NUMBER}.json"
|
||||
WORKTREE="/tmp/${PROJECT_NAME}-review-${PR_NUMBER}"
|
||||
LOCKFILE="/tmp/${PROJECT_NAME}-review.lock"
|
||||
STATUSFILE="/tmp/${PROJECT_NAME}-review-status"
|
||||
MAX_DIFF=25000
|
||||
REVIEW_TMPDIR=$(mktemp -d)
|
||||
|
||||
log() { printf '[%s] PR#%s %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$PR_NUMBER" "$*" >> "$LOGFILE"; }
|
||||
status() { printf '[%s] PR #%s: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$PR_NUMBER" "$*" > "$STATUSFILE"; log "$*"; }
|
||||
cleanup() { rm -rf "$REVIEW_TMPDIR" "$LOCKFILE" "$STATUSFILE" "/tmp/${PROJECT_NAME}-review-graph-${PR_NUMBER}.json"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# =============================================================================
|
||||
# LOG ROTATION
|
||||
# =============================================================================
|
||||
if [ -f "$LOGFILE" ] && [ "$(stat -c%s "$LOGFILE" 2>/dev/null || echo 0)" -gt 102400 ]; then
|
||||
mv "$LOGFILE" "$LOGFILE.old"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# RESOLVE FORGE REMOTE FOR GIT OPERATIONS
|
||||
# =============================================================================
|
||||
resolve_forge_remote
|
||||
|
||||
# =============================================================================
|
||||
# RESOLVE AGENT IDENTITY FOR .PROFILE REPO
|
||||
# =============================================================================
|
||||
resolve_agent_identity || true
|
||||
|
||||
# =============================================================================
|
||||
# MEMORY GUARD
|
||||
# =============================================================================
|
||||
memory_guard 1500
|
||||
|
||||
# =============================================================================
|
||||
# CONCURRENCY LOCK
|
||||
# =============================================================================
|
||||
AVAIL=$(awk '/MemAvailable/{printf "%d", $2/1024}' /proc/meminfo)
|
||||
[ "$AVAIL" -lt 1500 ] && { log "SKIP: ${AVAIL}MB available"; exit 0; }
|
||||
if [ -f "$LOCKFILE" ]; then
|
||||
LPID=$(cat "$LOCKFILE" 2>/dev/null || true)
|
||||
[ -n "$LPID" ] && kill -0 "$LPID" 2>/dev/null && { log "SKIP: locked"; exit 0; }
|
||||
rm -f "$LOCKFILE"
|
||||
fi
|
||||
echo $$ > "$LOCKFILE"
|
||||
|
||||
# =============================================================================
|
||||
# FETCH PR METADATA
|
||||
# =============================================================================
|
||||
status "fetching metadata"
|
||||
PR_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" "${API}/pulls/${PR_NUMBER}")
|
||||
PR_TITLE=$(printf '%s' "$PR_JSON" | jq -r '.title')
|
||||
|
|
@ -95,27 +45,15 @@ PR_BASE=$(printf '%s' "$PR_JSON" | jq -r '.base.ref')
|
|||
PR_SHA=$(printf '%s' "$PR_JSON" | jq -r '.head.sha')
|
||||
PR_STATE=$(printf '%s' "$PR_JSON" | jq -r '.state')
|
||||
log "${PR_TITLE} (${PR_HEAD}→${PR_BASE} ${PR_SHA:0:7})"
|
||||
|
||||
if [ "$PR_STATE" != "open" ]; then
|
||||
log "SKIP: state=${PR_STATE}"
|
||||
worktree_cleanup "$WORKTREE"
|
||||
rm -f "$OUTPUT_FILE" "$SID_FILE" 2>/dev/null || true
|
||||
exit 0
|
||||
log "SKIP: state=${PR_STATE}"; agent_kill_session "$SESSION"
|
||||
cd "${PROJECT_REPO_ROOT}"; git worktree remove "$WORKTREE" --force 2>/dev/null || true
|
||||
rm -rf "$WORKTREE" "$PHASE_FILE" "$OUTPUT_FILE" 2>/dev/null || true; exit 0
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# CI CHECK
|
||||
# =============================================================================
|
||||
CI_STATE=$(ci_commit_status "$PR_SHA")
|
||||
CI_NOTE=""
|
||||
if ! ci_passed "$CI_STATE"; then
|
||||
CI_NOTE=""; if ! ci_passed "$CI_STATE"; then
|
||||
ci_required_for_pr "$PR_NUMBER" && { log "SKIP: CI=${CI_STATE}"; exit 0; }
|
||||
CI_NOTE=" (not required — non-code PR)"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# DUPLICATE CHECK — skip if already reviewed at this SHA
|
||||
# =============================================================================
|
||||
CI_NOTE=" (not required — non-code PR)"; fi
|
||||
ALL_COMMENTS=$(forge_api_all "/issues/${PR_NUMBER}/comments")
|
||||
HAS_CMT=$(printf '%s' "$ALL_COMMENTS" | jq --arg s "$PR_SHA" \
|
||||
'[.[]|select(.body|contains("<!-- reviewed: "+$s+" -->"))]|length')
|
||||
|
|
@ -123,17 +61,13 @@ HAS_CMT=$(printf '%s' "$ALL_COMMENTS" | jq --arg s "$PR_SHA" \
|
|||
HAS_FML=$(forge_api_all "/pulls/${PR_NUMBER}/reviews" | jq --arg s "$PR_SHA" \
|
||||
'[.[]|select(.commit_id==$s)|select(.state!="COMMENT")]|length')
|
||||
[ "${HAS_FML:-0}" -gt 0 ] && [ "$FORCE" != "--force" ] && { log "SKIP: formal review"; exit 0; }
|
||||
|
||||
# =============================================================================
|
||||
# RE-REVIEW DETECTION
|
||||
# =============================================================================
|
||||
PREV_CONTEXT="" IS_RE_REVIEW=false PREV_SHA=""
|
||||
PREV_REV=$(printf '%s' "$ALL_COMMENTS" | jq -r --arg s "$PR_SHA" \
|
||||
'[.[]|select(.body|contains("<!-- reviewed:"))|select(.body|contains($s)|not)]|last // empty')
|
||||
if [ -n "$PREV_REV" ] && [ "$PREV_REV" != "null" ]; then
|
||||
PREV_BODY=$(printf '%s' "$PREV_REV" | jq -r '.body')
|
||||
PREV_SHA=$(printf '%s' "$PREV_BODY" | grep -oP '<!-- reviewed: \K[a-f0-9]+' | head -1)
|
||||
cd "${PROJECT_REPO_ROOT}"; git fetch "${FORGE_REMOTE}" "$PR_HEAD" 2>/dev/null || true
|
||||
cd "${PROJECT_REPO_ROOT}"; git fetch origin "$PR_HEAD" 2>/dev/null || true
|
||||
INCR=$(git diff "${PREV_SHA}..${PR_SHA}" 2>/dev/null | head -c "$MAX_DIFF") || true
|
||||
if [ -n "$INCR" ]; then
|
||||
IS_RE_REVIEW=true; log "re-review: previous at ${PREV_SHA:0:7}"
|
||||
|
|
@ -145,13 +79,6 @@ if [ -n "$PREV_REV" ] && [ "$PREV_REV" != "null" ]; then
|
|||
"${PREV_SHA:0:7}" "$PREV_BODY" "$DEV_SEC" "${PREV_SHA:0:7}" "${PR_SHA:0:7}" "$INCR")
|
||||
fi
|
||||
fi
|
||||
|
||||
# Recover session_id from .sid file (re-review continuity)
|
||||
agent_recover_session
|
||||
|
||||
# =============================================================================
|
||||
# FETCH DIFF
|
||||
# =============================================================================
|
||||
status "fetching diff"
|
||||
curl -s -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${API}/pulls/${PR_NUMBER}.diff" > "${REVIEW_TMPDIR}/full.diff"
|
||||
|
|
@ -159,25 +86,15 @@ FSIZE=$(stat -c%s "${REVIEW_TMPDIR}/full.diff" 2>/dev/null || echo 0)
|
|||
DIFF=$(head -c "$MAX_DIFF" "${REVIEW_TMPDIR}/full.diff")
|
||||
FILES=$(grep -E '^\+\+\+ b/' "${REVIEW_TMPDIR}/full.diff" | sed 's|^+++ b/||' | grep -v '/dev/null' | sort -u || true)
|
||||
DNOTE=""; [ "$FSIZE" -gt "$MAX_DIFF" ] && DNOTE=" (truncated from ${FSIZE} bytes)"
|
||||
|
||||
# =============================================================================
|
||||
# WORKTREE SETUP
|
||||
# =============================================================================
|
||||
cd "${PROJECT_REPO_ROOT}"
|
||||
git fetch "${FORGE_REMOTE}" "$PR_HEAD" 2>/dev/null || true
|
||||
|
||||
cd "${PROJECT_REPO_ROOT}"; git fetch origin "$PR_HEAD" 2>/dev/null || true
|
||||
if [ -d "$WORKTREE" ]; then
|
||||
cd "$WORKTREE"; git checkout --detach "$PR_SHA" 2>/dev/null || {
|
||||
cd "${PROJECT_REPO_ROOT}"; worktree_cleanup "$WORKTREE"
|
||||
git worktree add "$WORKTREE" "$PR_SHA" --detach 2>/dev/null; }
|
||||
else
|
||||
git worktree add "$WORKTREE" "$PR_SHA" --detach 2>/dev/null
|
||||
fi
|
||||
cd "${PROJECT_REPO_ROOT}"; git worktree remove "$WORKTREE" --force 2>/dev/null || true
|
||||
rm -rf "$WORKTREE"; git worktree add "$WORKTREE" "$PR_SHA" --detach 2>/dev/null; }
|
||||
else git worktree add "$WORKTREE" "$PR_SHA" --detach 2>/dev/null; fi
|
||||
status "preparing review session"
|
||||
|
||||
# =============================================================================
|
||||
# BUILD STRUCTURAL ANALYSIS GRAPH
|
||||
# =============================================================================
|
||||
status "preparing review"
|
||||
# ── Build structural analysis graph for changed files ────────────────────
|
||||
GRAPH_REPORT="/tmp/${PROJECT_NAME}-review-graph-${PR_NUMBER}.json"
|
||||
GRAPH_SECTION=""
|
||||
# shellcheck disable=SC2086
|
||||
|
|
@ -192,49 +109,42 @@ else
|
|||
log "WARN: build-graph.py failed — continuing without structural analysis"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# LOAD LESSONS FROM .PROFILE REPO (PRE-SESSION)
|
||||
# =============================================================================
|
||||
formula_prepare_profile_context
|
||||
|
||||
# =============================================================================
|
||||
# BUILD PROMPT
|
||||
# =============================================================================
|
||||
FORMULA=$(cat "${FACTORY_ROOT}/formulas/review-pr.toml")
|
||||
{
|
||||
printf 'You are the review agent for %s. Follow the formula to review PR #%s.\n\n' \
|
||||
"${FORGE_REPO}" "${PR_NUMBER}"
|
||||
printf 'You are the review agent for %s. Follow the formula to review PR #%s.\nYou MUST write PHASE:done to '\''%s'\'' when finished.\n\n' \
|
||||
"${FORGE_REPO}" "${PR_NUMBER}" "${PHASE_FILE}"
|
||||
printf '## PR Context\n**%s** (%s → %s) | SHA: %s | CI: %s%s\nRe-review: %s\n\n' \
|
||||
"$PR_TITLE" "$PR_HEAD" "$PR_BASE" "$PR_SHA" "$CI_STATE" "$CI_NOTE" "$IS_RE_REVIEW"
|
||||
printf '### Description\n%s\n\n### Changed Files\n%s\n\n### Diff%s\n```diff\n%s\n```\n' \
|
||||
"$PR_BODY" "$FILES" "$DNOTE" "$DIFF"
|
||||
[ -n "$PREV_CONTEXT" ] && printf '%s\n' "$PREV_CONTEXT"
|
||||
[ -n "$GRAPH_SECTION" ] && printf '%s\n' "$GRAPH_SECTION"
|
||||
formula_lessons_block
|
||||
printf '\n## Formula\n%s\n\n## Environment\nREVIEW_OUTPUT_FILE=%s\nFORGE_API=%s\nPR_NUMBER=%s\nFACTORY_ROOT=%s\n' \
|
||||
"$FORMULA" "$OUTPUT_FILE" "$API" "$PR_NUMBER" "$FACTORY_ROOT"
|
||||
printf '\n## Formula\n%s\n\n## Environment\nREVIEW_OUTPUT_FILE=%s\nPHASE_FILE=%s\nFORGE_API=%s\nPR_NUMBER=%s\nFACTORY_ROOT=%s\n' \
|
||||
"$FORMULA" "$OUTPUT_FILE" "$PHASE_FILE" "$API" "$PR_NUMBER" "$FACTORY_ROOT"
|
||||
printf 'NEVER echo the actual token — always reference ${FORGE_TOKEN} or ${FORGE_REVIEW_TOKEN}.\n'
|
||||
printf '\n## Completion\nAfter writing the JSON file to REVIEW_OUTPUT_FILE, stop.\nDo NOT write to any phase file — completion is automatic.\n'
|
||||
} > "${REVIEW_TMPDIR}/prompt.md"
|
||||
PROMPT=$(cat "${REVIEW_TMPDIR}/prompt.md")
|
||||
|
||||
# =============================================================================
|
||||
# RUN REVIEW AGENT
|
||||
# =============================================================================
|
||||
status "running review"
|
||||
rm -f "$OUTPUT_FILE"
|
||||
rm -f "$OUTPUT_FILE" "$PHASE_FILE"; agent_kill_session "$SESSION"
|
||||
export CLAUDE_MODEL="sonnet"
|
||||
create_agent_session "$SESSION" "$WORKTREE" "$PHASE_FILE" || { log "ERROR: session failed"; exit 1; }
|
||||
agent_inject_into_session "$SESSION" "$PROMPT"
|
||||
log "prompt injected (${#PROMPT} bytes, re-review: ${IS_RE_REVIEW})"
|
||||
|
||||
if [ "$IS_RE_REVIEW" = true ] && [ -n "$_AGENT_SESSION_ID" ]; then
|
||||
agent_run --resume "$_AGENT_SESSION_ID" --worktree "$WORKTREE" "$PROMPT"
|
||||
else
|
||||
agent_run --worktree "$WORKTREE" "$PROMPT"
|
||||
fi
|
||||
log "agent_run complete (re-review: ${IS_RE_REVIEW})"
|
||||
status "waiting for review"
|
||||
_REVIEW_CRASH=0
|
||||
review_cb() {
|
||||
log "phase: $1"
|
||||
case "$1" in
|
||||
PHASE:crashed)
|
||||
[ "$_REVIEW_CRASH" -gt 0 ] && return 0; _REVIEW_CRASH=$((_REVIEW_CRASH + 1))
|
||||
create_agent_session "${_MONITOR_SESSION}" "$WORKTREE" "$PHASE_FILE" 2>/dev/null && \
|
||||
agent_inject_into_session "${_MONITOR_SESSION}" "$PROMPT" ;;
|
||||
PHASE:done|PHASE:failed|PHASE:escalate) agent_kill_session "${_MONITOR_SESSION}" ;;
|
||||
esac
|
||||
}
|
||||
monitor_phase_loop "$PHASE_FILE" 600 "review_cb" "$SESSION"
|
||||
|
||||
# =============================================================================
|
||||
# PARSE REVIEW OUTPUT
|
||||
# =============================================================================
|
||||
REVIEW_JSON=""
|
||||
if [ -f "$OUTPUT_FILE" ]; then
|
||||
RAW=$(cat "$OUTPUT_FILE")
|
||||
|
|
@ -245,7 +155,6 @@ if [ -f "$OUTPUT_FILE" ]; then
|
|||
[ -n "${EXT:-}" ] && printf '%s' "$EXT" | jq -e '.verdict' >/dev/null 2>&1 && REVIEW_JSON="$EXT"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$REVIEW_JSON" ]; then
|
||||
log "ERROR: no valid review output"
|
||||
jq -n --arg b "## AI Review — Error\n<!-- review-error: ${PR_SHA} -->\nReview failed.\n---\n*${PR_SHA:0:7}*" \
|
||||
|
|
@ -253,15 +162,11 @@ if [ -z "$REVIEW_JSON" ]; then
|
|||
-H "Content-Type: application/json" "${API}/issues/${PR_NUMBER}/comments" -d @- || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERDICT=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict' | tr '[:lower:]' '[:upper:]' | tr '-' '_')
|
||||
REASON=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict_reason // ""')
|
||||
REVIEW_MD=$(printf '%s' "$REVIEW_JSON" | jq -r '.review_markdown // ""')
|
||||
log "verdict: ${VERDICT}"
|
||||
|
||||
# =============================================================================
|
||||
# POST REVIEW
|
||||
# =============================================================================
|
||||
status "posting review"
|
||||
RTYPE="Review"
|
||||
if [ "$IS_RE_REVIEW" = true ]; then
|
||||
|
|
@ -279,9 +184,6 @@ POST_RC=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
|||
[ "$POST_RC" != "201" ] && { log "ERROR: comment HTTP ${POST_RC}"; exit 1; }
|
||||
log "posted review comment"
|
||||
|
||||
# =============================================================================
|
||||
# POST FORMAL REVIEW
|
||||
# =============================================================================
|
||||
REVENT="COMMENT"
|
||||
case "$VERDICT" in APPROVE) REVENT="APPROVED" ;; REQUEST_CHANGES|DISCUSS) REVENT="REQUEST_CHANGES" ;; esac
|
||||
if [ "$REVENT" = "APPROVED" ]; then
|
||||
|
|
@ -302,21 +204,10 @@ curl -s -o /dev/null -X POST -H "Authorization: token ${FORGE_REVIEW_TOKEN}" \
|
|||
--data-binary @"${REVIEW_TMPDIR}/formal.json" >/dev/null 2>&1 || true
|
||||
log "formal ${REVENT} submitted"
|
||||
|
||||
# =============================================================================
|
||||
# FINAL CLEANUP
|
||||
# =============================================================================
|
||||
case "$VERDICT" in
|
||||
REQUEST_CHANGES|DISCUSS)
|
||||
# Keep session and worktree for re-review continuity
|
||||
log "keeping session for re-review (SID: ${_AGENT_SESSION_ID:0:12}...)"
|
||||
;;
|
||||
*)
|
||||
rm -f "$SID_FILE" "$OUTPUT_FILE"
|
||||
worktree_cleanup "$WORKTREE"
|
||||
;;
|
||||
REQUEST_CHANGES|DISCUSS) printf 'PHASE:awaiting_changes\nSHA:%s\n' "$PR_SHA" > "$PHASE_FILE" ;;
|
||||
*) rm -f "$PHASE_FILE" "$OUTPUT_FILE"; cd "${PROJECT_REPO_ROOT}"
|
||||
git worktree remove "$WORKTREE" --force 2>/dev/null || true
|
||||
rm -rf "$WORKTREE" 2>/dev/null || true ;;
|
||||
esac
|
||||
|
||||
# Write journal entry post-session
|
||||
profile_write_journal "review-${PR_NUMBER}" "Review PR #${PR_NUMBER} (${VERDICT})" "${VERDICT,,}" "" || true
|
||||
|
||||
log "DONE: ${VERDICT} (re-review: ${IS_RE_REVIEW})"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ FACTORY_ROOT="$(dirname "$SCRIPT_DIR")"
|
|||
# shellcheck source=../lib/env.sh
|
||||
source "$FACTORY_ROOT/lib/env.sh"
|
||||
|
||||
LOGFILE="${DISINTO_LOG_DIR}/site/collect-engagement.log"
|
||||
LOGFILE="${FACTORY_ROOT}/site/collect-engagement.log"
|
||||
log() {
|
||||
printf '[%s] collect-engagement: %s\n' \
|
||||
"$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ source "$FACTORY_ROOT/lib/env.sh"
|
|||
# shellcheck source=../lib/ci-helpers.sh
|
||||
source "$FACTORY_ROOT/lib/ci-helpers.sh" 2>/dev/null || true
|
||||
|
||||
LOGFILE="${DISINTO_LOG_DIR}/site/collect-metrics.log"
|
||||
LOGFILE="${FACTORY_ROOT}/site/collect-metrics.log"
|
||||
log() {
|
||||
printf '[%s] collect-metrics: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
|
||||
}
|
||||
|
|
@ -188,7 +188,7 @@ collect_agent_metrics() {
|
|||
local agent_name log_path age_min last_active
|
||||
for log_entry in dev/dev-agent.log review/review.log gardener/gardener.log \
|
||||
planner/planner.log predictor/predictor.log supervisor/supervisor.log \
|
||||
vault/vault.log; do
|
||||
action/action.log vault/vault.log; do
|
||||
agent_name=$(basename "$(dirname "$log_entry")")
|
||||
log_path="${FACTORY_ROOT}/${log_entry}"
|
||||
if [ -f "$log_path" ]; then
|
||||
|
|
|
|||
|
|
@ -397,10 +397,15 @@
|
|||
<div class="role">Detects <strong>infrastructure patterns</strong> — recurring failures, resource trends, emerging issues. Files predictions for triage.</div>
|
||||
<div class="trigger">Cron: daily</div>
|
||||
</div>
|
||||
<div class="agent-card">
|
||||
<div class="name">action-agent</div>
|
||||
<div class="role">Executes <strong>operational tasks</strong> defined as formulas — site deployments, data migrations, any multi-step procedure.</div>
|
||||
<div class="trigger">Cron: every 5 min</div>
|
||||
</div>
|
||||
<div class="agent-card">
|
||||
<div class="name">vault</div>
|
||||
<div class="role"><strong>Being redesigned.</strong> Moving to PR-based approval workflow on ops repo. See issues #73-#77.</div>
|
||||
<div class="trigger">Redesign in progress</div>
|
||||
<div class="role"><strong>Safety gate.</strong> Reviews dangerous actions before they execute. Auto-approves safe operations, escalates risky ones to a human.</div>
|
||||
<div class="trigger">Event-driven</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -446,11 +451,12 @@
|
|||
|
||||
<!-- Vault -->
|
||||
<div class="section">
|
||||
<h2>Vault — being redesigned</h2>
|
||||
<h2>Vault — quality gate</h2>
|
||||
<div class="concept">
|
||||
<div class="label">Redesign in progress</div>
|
||||
<p>The vault is being redesigned as a PR-based approval workflow on the ops repo. Instead of polling pending files, vault items will be created as PRs that require admin approval before execution.</p>
|
||||
<p><strong>See issues #73-#77</strong> for the design: #75 defines the vault.sh helper for creating vault PRs, #76 rewrites the dispatcher to poll for merged vault PRs, #77 adds branch protection requiring admin approval.</p>
|
||||
<div class="label">How it works</div>
|
||||
<p>The vault sits between agents and dangerous actions. Before an agent can execute a risky operation (force push, deploy, delete), the vault reviews the request.</p>
|
||||
<p><strong>Auto-approve</strong> — safe, well-understood operations pass through instantly. <strong>Escalate</strong> — risky or novel operations get sent to a human via Matrix. <strong>Reject</strong> — clearly unsafe actions are blocked.</p>
|
||||
<p>You define the boundaries. The vault enforces them. This is what lets you sleep while the factory runs.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -518,7 +524,8 @@ disinto/
|
|||
├── <span class="agent-name">predictor/</span> predictor-run.sh (daily cron executor)
|
||||
├── <span class="agent-name">planner/</span> planner-run.sh (weekly cron executor)
|
||||
├── <span class="agent-name">supervisor/</span> supervisor-run.sh (health monitoring)
|
||||
├── <span class="agent-name">vault/</span> vault-env.sh (vault redesign in progress, see #73-#77)
|
||||
├── <span class="agent-name">vault/</span> vault-poll.sh, vault-agent.sh, vault-fire.sh
|
||||
├── <span class="agent-name">action/</span> action-poll.sh, action-agent.sh
|
||||
├── <span class="agent-name">lib/</span> env.sh, agent-session.sh, ci-helpers.sh
|
||||
├── <span class="agent-name">projects/</span> *.toml per-project config
|
||||
├── <span class="agent-name">formulas/</span> TOML specs for multi-step agent tasks
|
||||
|
|
|
|||
350
skill/SKILL.md
Normal file
350
skill/SKILL.md
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
---
|
||||
name: disinto
|
||||
description: >-
|
||||
Operate the disinto autonomous code factory. Use when bootstrapping a new
|
||||
project with `disinto init`, managing factory agents, filing issues on the
|
||||
forge, reading agent journals, querying CI pipelines, checking the dependency
|
||||
graph, or inspecting factory health.
|
||||
license: AGPL-3.0
|
||||
metadata:
|
||||
author: johba
|
||||
version: "0.2.0"
|
||||
env_vars:
|
||||
required:
|
||||
- FORGE_TOKEN
|
||||
- FORGE_API
|
||||
- PROJECT_REPO_ROOT
|
||||
optional:
|
||||
- WOODPECKER_SERVER
|
||||
- WOODPECKER_TOKEN
|
||||
- WOODPECKER_REPO_ID
|
||||
tools:
|
||||
- bash
|
||||
- curl
|
||||
- jq
|
||||
- git
|
||||
---
|
||||
|
||||
# Disinto Factory Skill
|
||||
|
||||
You are the human's assistant for operating the disinto autonomous code factory.
|
||||
You ask the questions, explain the choices, and run the commands on the human's
|
||||
behalf. The human makes decisions; you execute.
|
||||
|
||||
Disinto manages eight agents that implement issues, review PRs, plan from a
|
||||
vision, predict risks, groom the backlog, gate actions, and keep the system
|
||||
healthy — all driven by cron and Claude.
|
||||
|
||||
## System requirements
|
||||
|
||||
Before bootstrapping, verify the target machine meets these minimums:
|
||||
|
||||
| Requirement | Detail |
|
||||
|-------------|--------|
|
||||
| **VPS** | 8 GB+ RAM (4 GB swap recommended) |
|
||||
| **Docker + Docker Compose** | Required for the default containerized stack |
|
||||
| **Claude Code CLI** | Authenticated with API access (`claude --version`) |
|
||||
| **`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`** | Set in the factory environment — prevents auto-update pings in production |
|
||||
| **Disk** | Sufficient for CI images, git mirrors, and agent worktrees (40 GB+ recommended) |
|
||||
| **tmux** | Required for persistent dev sessions |
|
||||
| **git, jq, python3, curl** | Used by agents and helper scripts |
|
||||
|
||||
Optional but recommended:
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| **sops + age** | Encrypt secrets at rest (`.env.enc`) |
|
||||
|
||||
## Bootstrapping with `disinto init`
|
||||
|
||||
The primary setup path. Walk the human through each step.
|
||||
|
||||
### Step 1 — Check prerequisites
|
||||
|
||||
Confirm Docker, Claude Code CLI, and required tools are installed:
|
||||
|
||||
```bash
|
||||
docker --version && docker compose version
|
||||
claude --version
|
||||
tmux -V && git --version && jq --version && python3 --version
|
||||
```
|
||||
|
||||
### Step 2 — Run `disinto init`
|
||||
|
||||
```bash
|
||||
disinto init <repo-url>
|
||||
```
|
||||
|
||||
Accepts GitHub, Codeberg, or any git URL. Common variations:
|
||||
|
||||
```bash
|
||||
disinto init https://github.com/org/repo # default (docker compose)
|
||||
disinto init org/repo --forge-url http://forge:3000 # custom forge URL
|
||||
disinto init org/repo --bare # bare-metal, no compose
|
||||
disinto init org/repo --yes # skip confirmation prompts
|
||||
```
|
||||
|
||||
### What `disinto init` does
|
||||
|
||||
1. **Generates `docker-compose.yml`** with four services: Forgejo, Woodpecker
|
||||
server, Woodpecker agent, and the agents container.
|
||||
2. **Starts a local Forgejo instance** via Docker (at `http://localhost:3000`).
|
||||
3. **Creates admin + bot users** (dev-bot, review-bot) with API tokens.
|
||||
4. **Creates the repo** on Forgejo and pushes the code.
|
||||
5. **Sets up Woodpecker CI** — OAuth2 app on Forgejo, activates the repo.
|
||||
6. **Generates `projects/<name>.toml`** — per-project config with paths, CI IDs,
|
||||
and forge URL.
|
||||
7. **Creates standard labels** (backlog, in-progress, blocked, etc.).
|
||||
8. **Configures git mirror remotes** if `[mirrors]` is set in the TOML.
|
||||
9. **Encrypts secrets** to `.env.enc` if sops + age are available.
|
||||
10. **Brings up the full docker compose stack**.
|
||||
|
||||
### Step 3 — Set environment variable
|
||||
|
||||
Ensure `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` is set in the factory
|
||||
environment (`.env` or the agents container). This prevents Claude Code from
|
||||
making auto-update and telemetry requests in production.
|
||||
|
||||
### Step 4 — Verify
|
||||
|
||||
```bash
|
||||
disinto status
|
||||
```
|
||||
|
||||
## Docker stack architecture
|
||||
|
||||
The default deployment is a docker-compose stack with four services:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ disinto-net │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌─────────────┐ ┌────────────┐ │
|
||||
│ │ Forgejo │ │ Woodpecker │ │ Woodpecker │ │
|
||||
│ │ (forge) │◀─│ (CI server)│◀─│ (agent) │ │
|
||||
│ │ :3000 │ │ :8000 │ │ │ │
|
||||
│ └──────────┘ └─────────────┘ └────────────┘ │
|
||||
│ ▲ │
|
||||
│ │ │
|
||||
│ ┌─────┴──────────────────────────────────────┐ │
|
||||
│ │ agents │ │
|
||||
│ │ (cron → dev, review, gardener, planner, │ │
|
||||
│ │ predictor, supervisor, action, vault) │ │
|
||||
│ │ Claude CLI mounted from host │ │
|
||||
│ └────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| Service | Image | Purpose |
|
||||
|---------|-------|---------|
|
||||
| **forgejo** | `codeberg.org/forgejo/forgejo:11.0` | Git forge, issue tracker, PR reviews |
|
||||
| **woodpecker** | `woodpeckerci/woodpecker-server:v3` | CI server, triggers on push |
|
||||
| **woodpecker-agent** | `woodpeckerci/woodpecker-agent:v3` | Runs CI pipelines in Docker |
|
||||
| **agents** | `./docker/agents` (custom) | All eight factory agents, driven by cron |
|
||||
|
||||
The agents container mounts the Claude CLI binary and `~/.claude` credentials
|
||||
from the host. Secrets are loaded from `.env` (or decrypted from `.env.enc`).
|
||||
|
||||
## Git mirror
|
||||
|
||||
The factory assumes a local git mirror on the Forgejo instance to avoid
|
||||
rate limits from upstream forges (GitHub, Codeberg). When `disinto init` runs:
|
||||
|
||||
1. The repo is cloned from the upstream URL.
|
||||
2. A `forgejo` remote is added pointing to the local Forgejo instance.
|
||||
3. All branches and tags are pushed to Forgejo.
|
||||
4. If `[mirrors]` is configured in the project TOML, additional remotes
|
||||
(e.g. GitHub, Codeberg) are set up and synced via `lib/mirrors.sh`.
|
||||
|
||||
All agent work happens against the local Forgejo forge. This means:
|
||||
- No GitHub/Codeberg API rate limits on polling.
|
||||
- CI triggers are local (Woodpecker watches Forgejo webhooks).
|
||||
- Mirror pushes are fire-and-forget background operations after merge.
|
||||
|
||||
To configure mirrors in the project TOML:
|
||||
|
||||
```toml
|
||||
[mirrors]
|
||||
github = "git@github.com:user/repo.git"
|
||||
codeberg = "git@codeberg.org:user/repo.git"
|
||||
```
|
||||
|
||||
## Required environment
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `FORGE_TOKEN` | Forgejo/Gitea API token with repo scope |
|
||||
| `FORGE_API` | Base API URL, e.g. `https://forge.example/api/v1/repos/owner/repo` |
|
||||
| `PROJECT_REPO_ROOT` | Absolute path to the checked-out disinto repository |
|
||||
|
||||
Optional:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `WOODPECKER_SERVER` | Woodpecker CI base URL (for pipeline queries) |
|
||||
| `WOODPECKER_TOKEN` | Woodpecker API bearer token |
|
||||
| `WOODPECKER_REPO_ID` | Numeric repo ID in Woodpecker |
|
||||
|
||||
## The eight agents
|
||||
|
||||
| Agent | Role | Runs via |
|
||||
|-------|------|----------|
|
||||
| **Dev** | Picks backlog issues, implements in worktrees, opens PRs | `dev/dev-poll.sh` (cron) |
|
||||
| **Review** | Reviews PRs against conventions, approves or requests changes | `review/review-poll.sh` (cron) |
|
||||
| **Gardener** | Grooms backlog: dedup, quality gates, dust bundling, stale cleanup | `gardener/gardener-run.sh` (cron 0,6,12,18 UTC) |
|
||||
| **Planner** | Tracks vision progress, maintains prerequisite tree, files constraint issues | `planner/planner-run.sh` (cron daily 07:00 UTC) |
|
||||
| **Predictor** | Challenges claims, detects structural risks, files predictions | `predictor/predictor-run.sh` (cron daily 06:00 UTC) |
|
||||
| **Supervisor** | Monitors health (RAM, disk, CI, agents), auto-fixes, escalates | `supervisor/supervisor-run.sh` (cron */20) |
|
||||
| **Action** | Executes operational tasks dispatched by planner via formulas | `action/action-poll.sh` (cron) |
|
||||
| **Vault** | Gates dangerous actions, manages resource procurement | `vault/vault-poll.sh` (cron) |
|
||||
|
||||
### How agents interact
|
||||
|
||||
```
|
||||
Planner ──creates-issues──▶ Backlog ◀──grooms── Gardener
|
||||
│ │
|
||||
│ ▼
|
||||
│ Dev (implements)
|
||||
│ │
|
||||
│ ▼
|
||||
│ Review (approves/rejects)
|
||||
│ │
|
||||
│ ▼
|
||||
▼ Merged
|
||||
Predictor ──challenges──▶ Planner (triages predictions)
|
||||
Supervisor ──monitors──▶ All agents (health, escalation)
|
||||
Vault ──gates──▶ Action, Dev (dangerous operations)
|
||||
```
|
||||
|
||||
### Issue lifecycle
|
||||
|
||||
`backlog` → `in-progress` → PR → CI → review → merge → closed.
|
||||
|
||||
Key labels: `backlog`, `priority`, `in-progress`, `blocked`, `underspecified`,
|
||||
`tech-debt`, `vision`, `action`, `prediction/unreviewed`.
|
||||
|
||||
Issues declare dependencies in a `## Dependencies` section listing `#N`
|
||||
references. Dev-poll only picks issues whose dependencies are all closed.
|
||||
|
||||
## Available scripts
|
||||
|
||||
- **`scripts/factory-status.sh`** — Show agent status, open issues, and CI
|
||||
pipeline state. Pass `--agents`, `--issues`, or `--ci` for specific sections.
|
||||
- **`scripts/file-issue.sh`** — Create an issue on the forge with proper labels
|
||||
and formatting. Pass `--title`, `--body`, and optionally `--labels`.
|
||||
- **`scripts/read-journal.sh`** — Read agent journal entries. Pass agent name
|
||||
(`planner`, `supervisor`) and optional `--date YYYY-MM-DD`.
|
||||
|
||||
## Common workflows
|
||||
|
||||
### 1. Bootstrap a new project
|
||||
|
||||
Walk the human through `disinto init`:
|
||||
|
||||
```bash
|
||||
# 1. Verify prerequisites
|
||||
docker --version && claude --version
|
||||
|
||||
# 2. Bootstrap
|
||||
disinto init https://github.com/org/repo
|
||||
|
||||
# 3. Verify
|
||||
disinto status
|
||||
```
|
||||
|
||||
### 2. Check factory health
|
||||
|
||||
```bash
|
||||
bash scripts/factory-status.sh
|
||||
```
|
||||
|
||||
This shows: which agents are active, recent open issues, and CI pipeline
|
||||
status. Use `--agents` for just the agent status section.
|
||||
|
||||
### 3. Read what the planner decided today
|
||||
|
||||
```bash
|
||||
bash scripts/read-journal.sh planner
|
||||
```
|
||||
|
||||
Returns today's planner journal: predictions triaged, prerequisite tree
|
||||
updates, top constraints, issues created, and observations.
|
||||
|
||||
### 4. File a new issue
|
||||
|
||||
```bash
|
||||
bash scripts/file-issue.sh --title "fix: broken auth flow" \
|
||||
--body "$(cat scripts/../templates/issue-template.md)" \
|
||||
--labels backlog
|
||||
```
|
||||
|
||||
Or generate the body inline — the template shows the expected format with
|
||||
acceptance criteria and affected files sections.
|
||||
|
||||
### 5. Check the dependency graph
|
||||
|
||||
```bash
|
||||
python3 "${PROJECT_REPO_ROOT}/lib/build-graph.py" \
|
||||
--project-root "${PROJECT_REPO_ROOT}" \
|
||||
--output /tmp/graph-report.json
|
||||
cat /tmp/graph-report.json | jq '.analyses'
|
||||
```
|
||||
|
||||
The graph builder parses VISION.md, the prerequisite tree, formulas, and open
|
||||
issues. It detects: orphan issues (not referenced), dependency cycles,
|
||||
disconnected clusters, bottleneck nodes, and thin objectives.
|
||||
|
||||
### 6. Query a specific CI pipeline
|
||||
|
||||
```bash
|
||||
bash scripts/factory-status.sh --ci
|
||||
```
|
||||
|
||||
Or query Woodpecker directly:
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer ${WOODPECKER_TOKEN}" \
|
||||
"${WOODPECKER_SERVER}/api/repos/${WOODPECKER_REPO_ID}/pipelines?per_page=5" \
|
||||
| jq '.[] | {number, status, commit: .commit[:8], branch}'
|
||||
```
|
||||
|
||||
### 7. Manage the docker stack
|
||||
|
||||
```bash
|
||||
disinto up # start all services
|
||||
disinto down # stop all services
|
||||
disinto logs # tail all service logs
|
||||
disinto logs forgejo # tail specific service
|
||||
disinto shell # shell into agents container
|
||||
```
|
||||
|
||||
### 8. Read and interpret VISION.md progress
|
||||
|
||||
Read `VISION.md` at the repo root for the full vision. Then cross-reference
|
||||
with the prerequisite tree:
|
||||
|
||||
```bash
|
||||
cat "${OPS_REPO_ROOT}/prerequisites.md"
|
||||
```
|
||||
|
||||
The prerequisite tree maps vision objectives to concrete issues. Items marked
|
||||
`[x]` are complete; items marked `[ ]` show what blocks progress. The planner
|
||||
updates this daily.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Single-threaded pipeline**: only one issue is in-progress per project at a
|
||||
time. Don't file issues expecting parallel work.
|
||||
- **Secrets via env vars only**: never embed secrets in issue bodies, PR
|
||||
descriptions, or comments. Use `$VAR_NAME` references.
|
||||
- **Formulas are not skills**: formulas in `formulas/` are TOML issue templates
|
||||
for multi-step agent tasks. Skills teach assistants; formulas drive agents.
|
||||
- **Predictor journals**: the predictor does not write journal files. Its memory
|
||||
lives in `prediction/unreviewed` and `prediction/actioned` issues.
|
||||
- **State files**: agent activity is tracked via `state/.{agent}-active` files.
|
||||
These are presence files, not logs.
|
||||
- **ShellCheck required**: all `.sh` files must pass ShellCheck. CI enforces this.
|
||||
- **Local forge is the source of truth**: all agent work targets the local
|
||||
Forgejo instance. Upstream mirrors are synced after merge.
|
||||
- **`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`**: must be set in production
|
||||
to prevent Claude Code from making auto-update requests.
|
||||
114
skill/scripts/factory-status.sh
Executable file
114
skill/scripts/factory-status.sh
Executable file
|
|
@ -0,0 +1,114 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# factory-status.sh — query agent status, open issues, and CI pipelines
|
||||
#
|
||||
# Usage: factory-status.sh [--agents] [--issues] [--ci] [--help]
|
||||
# No flags: show all sections
|
||||
# --agents: show only agent activity status
|
||||
# --issues: show only open issues summary
|
||||
# --ci: show only CI pipeline status
|
||||
#
|
||||
# Required env: FORGE_TOKEN, FORGE_API, PROJECT_REPO_ROOT
|
||||
# Optional env: WOODPECKER_SERVER, WOODPECKER_TOKEN, WOODPECKER_REPO_ID
|
||||
|
||||
usage() {
|
||||
sed -n '3,10s/^# //p' "$0"
|
||||
exit 0
|
||||
}
|
||||
|
||||
show_agents=false
|
||||
show_issues=false
|
||||
show_ci=false
|
||||
show_all=true
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--agents) show_agents=true; show_all=false; shift ;;
|
||||
--issues) show_issues=true; show_all=false; shift ;;
|
||||
--ci) show_ci=true; show_all=false; shift ;;
|
||||
--help|-h) usage ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
: "${FORGE_TOKEN:?FORGE_TOKEN is required}"
|
||||
: "${FORGE_API:?FORGE_API is required}"
|
||||
: "${PROJECT_REPO_ROOT:?PROJECT_REPO_ROOT is required}"
|
||||
|
||||
forge_get() {
|
||||
curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Accept: application/json" \
|
||||
"${FORGE_API}$1"
|
||||
}
|
||||
|
||||
# --- Agent status ---
|
||||
print_agent_status() {
|
||||
echo "## Agent Status"
|
||||
echo ""
|
||||
local state_dir="${PROJECT_REPO_ROOT}/state"
|
||||
local agents=(dev review gardener supervisor planner predictor action vault)
|
||||
for agent in "${agents[@]}"; do
|
||||
local state_file="${state_dir}/.${agent}-active"
|
||||
if [[ -f "$state_file" ]]; then
|
||||
echo " ${agent}: ACTIVE (since $(stat -c '%y' "$state_file" 2>/dev/null | cut -d. -f1 || echo 'unknown'))"
|
||||
else
|
||||
echo " ${agent}: idle"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
}
|
||||
|
||||
# --- Open issues ---
|
||||
print_open_issues() {
|
||||
echo "## Open Issues"
|
||||
echo ""
|
||||
local issues
|
||||
issues=$(forge_get "/issues?state=open&type=issues&limit=50&sort=created&direction=desc" 2>/dev/null) || {
|
||||
echo " (failed to fetch issues from forge)"
|
||||
echo ""
|
||||
return
|
||||
}
|
||||
local count
|
||||
count=$(echo "$issues" | jq 'length')
|
||||
echo " Total open: ${count}"
|
||||
echo ""
|
||||
|
||||
# Group by key labels
|
||||
for label in backlog priority in-progress blocked; do
|
||||
local labeled
|
||||
labeled=$(echo "$issues" | jq --arg l "$label" '[.[] | select(.labels[]?.name == $l)]')
|
||||
local n
|
||||
n=$(echo "$labeled" | jq 'length')
|
||||
if [[ "$n" -gt 0 ]]; then
|
||||
echo " [${label}] (${n}):"
|
||||
echo "$labeled" | jq -r '.[] | " #\(.number) \(.title)"' | head -10
|
||||
echo ""
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# --- CI pipelines ---
|
||||
print_ci_status() {
|
||||
echo "## CI Pipelines"
|
||||
echo ""
|
||||
if [[ -z "${WOODPECKER_SERVER:-}" || -z "${WOODPECKER_TOKEN:-}" || -z "${WOODPECKER_REPO_ID:-}" ]]; then
|
||||
echo " (Woodpecker not configured — set WOODPECKER_SERVER, WOODPECKER_TOKEN, WOODPECKER_REPO_ID)"
|
||||
echo ""
|
||||
return
|
||||
fi
|
||||
local pipelines
|
||||
pipelines=$(curl -sf -H "Authorization: Bearer ${WOODPECKER_TOKEN}" \
|
||||
"${WOODPECKER_SERVER}/api/repos/${WOODPECKER_REPO_ID}/pipelines?per_page=10" 2>/dev/null) || {
|
||||
echo " (failed to fetch pipelines from Woodpecker)"
|
||||
echo ""
|
||||
return
|
||||
}
|
||||
echo "$pipelines" | jq -r '.[] | " #\(.number) [\(.status)] \(.branch) \(.commit[:8]) — \(.message // "" | split("\n")[0])"' | head -10
|
||||
echo ""
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
if $show_all || $show_agents; then print_agent_status; fi
|
||||
if $show_all || $show_issues; then print_open_issues; fi
|
||||
if $show_all || $show_ci; then print_ci_status; fi
|
||||
91
skill/scripts/file-issue.sh
Executable file
91
skill/scripts/file-issue.sh
Executable file
|
|
@ -0,0 +1,91 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# file-issue.sh — create an issue on the forge with labels
|
||||
#
|
||||
# Usage: file-issue.sh --title TITLE --body BODY [--labels LABEL1,LABEL2] [--help]
|
||||
#
|
||||
# Required env: FORGE_TOKEN, FORGE_API
|
||||
|
||||
usage() {
|
||||
sed -n '3,8s/^# //p' "$0"
|
||||
exit 0
|
||||
}
|
||||
|
||||
title=""
|
||||
body=""
|
||||
labels=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--title) title="$2"; shift 2 ;;
|
||||
--body) body="$2"; shift 2 ;;
|
||||
--labels) labels="$2"; shift 2 ;;
|
||||
--help|-h) usage ;;
|
||||
*) printf 'file-issue: unknown option: %s\n' "$1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
: "${FORGE_TOKEN:?FORGE_TOKEN is required}"
|
||||
: "${FORGE_API:?FORGE_API is required}"
|
||||
|
||||
if [[ -z "$title" ]]; then
|
||||
echo "Error: --title is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$body" ]]; then
|
||||
echo "Error: --body is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Resolve label names to IDs ---
|
||||
label_ids="[]"
|
||||
if [[ -n "$labels" ]]; then
|
||||
all_labels=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Accept: application/json" \
|
||||
"${FORGE_API}/labels?limit=50" 2>/dev/null) || {
|
||||
echo "Warning: could not fetch labels, creating issue without labels" >&2
|
||||
all_labels="[]"
|
||||
}
|
||||
label_ids="["
|
||||
first=true
|
||||
IFS=',' read -ra label_arr <<< "$labels"
|
||||
for lname in "${label_arr[@]}"; do
|
||||
lname=$(echo "$lname" | xargs) # trim whitespace
|
||||
lid=$(echo "$all_labels" | jq -r --arg n "$lname" '.[] | select(.name == $n) | .id')
|
||||
if [[ -n "$lid" ]]; then
|
||||
if ! $first; then label_ids+=","; fi
|
||||
label_ids+="$lid"
|
||||
first=false
|
||||
else
|
||||
echo "Warning: label '${lname}' not found, skipping" >&2
|
||||
fi
|
||||
done
|
||||
label_ids+="]"
|
||||
fi
|
||||
|
||||
# --- Secret scan (refuse to post bodies containing obvious secrets) ---
|
||||
if echo "$body" | grep -qiE '(sk-[a-zA-Z0-9]{20,}|ghp_[a-zA-Z0-9]{36}|AKIA[A-Z0-9]{16}|-----BEGIN (RSA |EC )?PRIVATE KEY)'; then
|
||||
echo "Error: body appears to contain a secret — refusing to post" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Create the issue ---
|
||||
payload=$(jq -n \
|
||||
--arg t "$title" \
|
||||
--arg b "$body" \
|
||||
--argjson l "$label_ids" \
|
||||
'{title: $t, body: $b, labels: $l}')
|
||||
|
||||
response=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
"${FORGE_API}/issues") || {
|
||||
echo "Error: failed to create issue" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
number=$(echo "$response" | jq -r '.number')
|
||||
url=$(echo "$response" | jq -r '.html_url')
|
||||
echo "Created issue #${number}: ${url}"
|
||||
93
skill/scripts/read-journal.sh
Executable file
93
skill/scripts/read-journal.sh
Executable file
|
|
@ -0,0 +1,93 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# read-journal.sh — read agent journal entries
|
||||
#
|
||||
# Usage: read-journal.sh AGENT [--date YYYY-MM-DD] [--list] [--help]
|
||||
# AGENT: planner, supervisor, or predictor
|
||||
# --date: specific date (default: today)
|
||||
# --list: list available journal dates instead of reading
|
||||
#
|
||||
# Required env: PROJECT_REPO_ROOT
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
read-journal.sh AGENT [--date YYYY-MM-DD] [--list] [--help]
|
||||
AGENT: planner, supervisor, or predictor
|
||||
--date: specific date (default: today)
|
||||
--list: list available journal dates instead of reading
|
||||
USAGE
|
||||
exit 0
|
||||
}
|
||||
|
||||
agent=""
|
||||
target_date=$(date +%Y-%m-%d)
|
||||
list_mode=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--date) target_date="$2"; shift 2 ;;
|
||||
--list) list_mode=true; shift ;;
|
||||
--help|-h) usage ;;
|
||||
-*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
*)
|
||||
if [[ -z "$agent" ]]; then
|
||||
agent="$1"
|
||||
else
|
||||
echo "Unexpected argument: $1" >&2; exit 1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
: "${OPS_REPO_ROOT:?OPS_REPO_ROOT is required}"
|
||||
|
||||
if [[ -z "$agent" ]]; then
|
||||
echo "Error: agent name is required (planner, supervisor, predictor)" >&2
|
||||
echo "" >&2
|
||||
usage
|
||||
fi
|
||||
|
||||
# --- Resolve journal directory ---
|
||||
case "$agent" in
|
||||
planner) journal_dir="${OPS_REPO_ROOT}/journal/planner" ;;
|
||||
supervisor) journal_dir="${OPS_REPO_ROOT}/journal/supervisor" ;;
|
||||
predictor)
|
||||
echo "The predictor does not write journal files."
|
||||
echo "Its memory lives in forge issues labeled 'prediction/unreviewed' and 'prediction/actioned'."
|
||||
echo ""
|
||||
echo "Query predictions with:"
|
||||
echo " curl -sH 'Authorization: token \${FORGE_TOKEN}' '\${FORGE_API}/issues?state=open&labels=prediction%2Funreviewed'"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Error: unknown agent '${agent}'" >&2
|
||||
echo "Available: planner, supervisor, predictor" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ ! -d "$journal_dir" ]]; then
|
||||
echo "No journal directory found at ${journal_dir}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- List mode ---
|
||||
if $list_mode; then
|
||||
echo "Available journal dates for ${agent}:"
|
||||
find "$journal_dir" -maxdepth 1 -name '*.md' -printf '%f\n' 2>/dev/null | sed 's|\.md$||' | sort -r | head -20
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Read specific date ---
|
||||
journal_file="${journal_dir}/${target_date}.md"
|
||||
if [[ -f "$journal_file" ]]; then
|
||||
cat "$journal_file"
|
||||
else
|
||||
echo "No journal entry for ${agent} on ${target_date}" >&2
|
||||
echo "" >&2
|
||||
echo "Recent entries:" >&2
|
||||
find "$journal_dir" -maxdepth 1 -name '*.md' -printf '%f\n' 2>/dev/null | sed 's|\.md$||' | sort -r | head -5 >&2
|
||||
exit 1
|
||||
fi
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue