Merge pull request 'fix: refactor: architect design phase should be bash-driven with stateful session resumption (#491)' (#497) from fix/issue-491 into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful

This commit is contained in:
dev-bot 2026-04-09 10:31:50 +00:00
commit 80e19f8e51
3 changed files with 445 additions and 369 deletions

View file

@ -11,7 +11,7 @@ converses with humans through PR comments.
- **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`
- **Mechanism**: Bash-driven orchestration in `architect-run.sh`, pitching formula via `formulas/run-architect.toml`
- **Identity**: `architect-bot` on Forgejo
## Responsibilities
@ -29,13 +29,45 @@ converses with humans through PR comments.
## Formula
The architect is driven by `formulas/run-architect.toml`. This formula defines
The architect pitching 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
- Pitch: creating structured sprint PRs
- Sub-issue filing: creating concrete implementation issues
## Bash-driven design phase
The design phase (ACCEPT → research → questions → answers → sub-issues) is
orchestrated by bash in `architect-run.sh`, not by the formula. This ensures:
- **Deterministic state detection**: Bash reads the Forgejo reviews API to detect
ACCEPT/REJECT decisions — no model-dependent API parsing
- **Human guidance injection**: Review body text from ACCEPT reviews is injected
directly into the research prompt as context
- **Stateful session resumption**: When answers arrive on a subsequent run, the
saved Claude session is resumed (`--resume session_id`), preserving full
codebase context from the research phase
- **REJECT without model**: Rejections are handled entirely in bash (close PR,
delete branch, remove in-progress label, journal) — no model invocation needed
### State transitions (bash-driven)
```
New vision issue → pitch PR (model)
ACCEPT review → research + questions (model, session saved)
Answers received → sub-issue filing (model, session resumed)
REJECT review → close PR + journal (bash only)
```
### Per-PR session files
Session IDs are saved per-PR in `/tmp/architect-sessions-{project}/pr-{number}.sid`.
This allows multiple architect PRs to be in different design phases simultaneously,
each with its own resumable session context.
## Execution
Run via `architect/architect-run.sh`, which:
@ -43,13 +75,12 @@ Run via `architect/architect-run.sh`, which:
- Cleans up per-issue scratch files from previous runs (`/tmp/architect-{project}-scratch-*.md`)
- Sources shared libraries (env.sh, formula-session.sh)
- Uses FORGE_ARCHITECT_TOKEN for authentication
- Processes existing architect PRs via bash-driven design phase
- Loads the formula and builds context from VISION.md, AGENTS.md, and ops repo
- Executes the formula via `agent_run`
- Executes the formula via `agent_run` for new pitches
**Multi-sprint pitching**: The architect pitches up to 3 sprints per run. The pitch budget is `3 <open architect PRs>`. After handling existing PRs (accept/reject/answer parsing), the architect selects up to `pitch_budget` vision issues (skipping any already with an open architect PR or `in-progress` label), then writes one per-issue scratch file (`/tmp/architect-{project}-scratch-{issue_number}.md`) and creates one sprint PR per scratch file.
**Session resumption (answer_parsing)**: When processing human answers on a PR in the `questions` phase (PR body has `## Design forks` + question comments), `architect-run.sh` resumes the prior Claude session (from `SID_FILE`) rather than starting fresh. This preserves deep codebase understanding from the research phase so sub-issues include specific file references.
## Cron
Suggested cron entry (every 6 hours):
@ -68,3 +99,4 @@ empty file not created, just document it).
- #100: Architect formula — research + design fork identification
- #101: Architect formula — sprint PR creation with questions
- #102: Architect formula — answer parsing + sub-issue filing
- #491: Refactor — bash-driven design phase with stateful session resumption

View file

@ -10,7 +10,12 @@
# 2. Precondition checks: skip if no work (no vision issues, no responses)
# 3. Load formula (formulas/run-architect.toml)
# 4. Context: VISION.md, AGENTS.md, ops:prerequisites.md, structural graph
# 5. agent_run(worktree, prompt) → Claude decomposes vision into sprints
# 5. Bash-driven design phase:
# a. Fetch reviews API for ACCEPT/REJECT detection (deterministic)
# b. REJECT: handled entirely in bash (close PR, delete branch, journal)
# c. ACCEPT: invoke claude with human guidance injected into prompt
# d. Answers: resume saved session with answers injected
# 6. New pitches: agent_run(worktree, prompt)
#
# Precondition checks (bash before model):
# - Skip if no vision issues AND no open architect PRs
@ -47,6 +52,9 @@ LOG_FILE="${DISINTO_LOG_DIR}/architect/architect.log"
LOGFILE="$LOG_FILE"
# shellcheck disable=SC2034 # consumed by agent-sdk.sh
SID_FILE="/tmp/architect-session-${PROJECT_NAME}.sid"
# Per-PR session files for stateful resumption across runs
SID_DIR="/tmp/architect-sessions-${PROJECT_NAME}"
mkdir -p "$SID_DIR"
SCRATCH_FILE="/tmp/architect-${PROJECT_NAME}-scratch.md"
SCRATCH_FILE_PREFIX="/tmp/architect-${PROJECT_NAME}-scratch"
WORKTREE="/tmp/${PROJECT_NAME}-architect-run"
@ -92,88 +100,162 @@ build_graph_section
SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
# ── Build prompt ─────────────────────────────────────────────────────────
# ── Build prompt footer ──────────────────────────────────────────────────
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.
# ── Design phase: bash-driven review detection ────────────────────────────
# Fetch PR reviews from Forgejo API (deterministic, not model-dependent).
# Sets global output variables (not stdout — guidance text is often multiline):
# REVIEW_DECISION — ACCEPT|REJECT|NONE
# REVIEW_GUIDANCE — human guidance text (review body or comment text)
# Args: pr_number
fetch_pr_review_decision() {
local pr_num="$1"
REVIEW_DECISION="NONE"
REVIEW_GUIDANCE=""
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.
# Step 1: Check PR reviews (Forgejo review UI) — takes precedence
local reviews_json
reviews_json=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/repos/${FORGE_OPS_REPO}/pulls/${pr_num}/reviews" 2>/dev/null) || reviews_json='[]'
## Project context
${CONTEXT_BLOCK}
${GRAPH_SECTION}
${SCRATCH_CONTEXT}
$(formula_lessons_block)
## Formula
${FORMULA_CONTENT}
# Find most recent non-bot review with a decision state
local review_decision review_body
review_decision=$(printf '%s' "$reviews_json" | jq -r '
[.[] | select(.user.login | test("bot$"; "i") | not)
| select(.state == "APPROVED" or .state == "REQUEST_CHANGES")]
| last | .state // empty
' 2>/dev/null) || review_decision=""
review_body=$(printf '%s' "$reviews_json" | jq -r '
[.[] | select(.user.login | test("bot$"; "i") | not)
| select(.state == "APPROVED" or .state == "REQUEST_CHANGES")]
| last | .body // empty
' 2>/dev/null) || review_body=""
${SCRATCH_INSTRUCTION}
${PROMPT_FOOTER}
_PROMPT_EOF_
if [ "$review_decision" = "APPROVED" ]; then
REVIEW_DECISION="ACCEPT"
REVIEW_GUIDANCE="$review_body"
return 0
elif [ "$review_decision" = "REQUEST_CHANGES" ]; then
REVIEW_DECISION="REJECT"
REVIEW_GUIDANCE="$review_body"
return 0
fi
# Step 2: Fallback — check PR comments for ACCEPT/REJECT text
local comments_json
comments_json=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/repos/${FORGE_OPS_REPO}/issues/${pr_num}/comments" 2>/dev/null) || comments_json='[]'
# Find most recent comment with ACCEPT or REJECT (case insensitive)
local comment_body
comment_body=$(printf '%s' "$comments_json" | jq -r '
[.[] | select(.body | test("(?i)^\\s*(ACCEPT|REJECT)"))] | last | .body // empty
' 2>/dev/null) || comment_body=""
if [ -n "$comment_body" ]; then
if printf '%s' "$comment_body" | grep -qiE '^\s*ACCEPT'; then
REVIEW_DECISION="ACCEPT"
# Extract guidance text after ACCEPT (e.g., "ACCEPT — use SSH approach" → "use SSH approach")
REVIEW_GUIDANCE=$(printf '%s' "$comment_body" | sed -n 's/^[[:space:]]*[Aa][Cc][Cc][Ee][Pp][Tt][[:space:]]*[—:-]*[[:space:]]*//p' | head -1)
# If guidance is empty on first line, use rest of comment
if [ -z "$REVIEW_GUIDANCE" ]; then
REVIEW_GUIDANCE=$(printf '%s' "$comment_body" | tail -n +2)
fi
elif printf '%s' "$comment_body" | grep -qiE '^\s*REJECT'; then
REVIEW_DECISION="REJECT"
REVIEW_GUIDANCE=$(printf '%s' "$comment_body" | sed -n 's/^[[:space:]]*[Rr][Ee][Jj][Ee][Cc][Tt][[:space:]]*[—:-]*[[:space:]]*//p' | head -1)
if [ -z "$REVIEW_GUIDANCE" ]; then
REVIEW_GUIDANCE=$(printf '%s' "$comment_body" | tail -n +2)
fi
fi
fi
}
PROMPT=$(build_architect_prompt)
# Handle REJECT entirely in bash — no model invocation needed.
# Args: pr_number, pr_head_branch, rejection_reason
handle_reject() {
local pr_num="$1"
local pr_branch="$2"
local reason="$3"
# ── Create worktree ──────────────────────────────────────────────────────
formula_worktree_setup "$WORKTREE"
log "Handling REJECT for PR #${pr_num}: ${reason}"
# ── Detect if PR is in questions-awaiting-answers phase ──────────────────
# A PR is in the questions phase if it has a `## Design forks` section and
# question comments. We check this to decide whether to resume the session
# from the research/questions run (preserves codebase context for answer parsing).
detect_questions_phase() {
local pr_number=""
local pr_body=""
# Close the PR via Forgejo API
curl -sf -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \
"${FORGE_API}/repos/${FORGE_OPS_REPO}/pulls/${pr_num}" \
-d '{"state":"closed"}' >/dev/null 2>&1 || log "WARN: failed to close PR #${pr_num}"
# Get open architect PRs on ops repo
local ops_repo="${OPS_REPO_ROOT:-/home/agent/data/ops}"
if [ ! -d "${ops_repo}/.git" ]; then
return 1
# Delete the branch via Forgejo API
if [ -n "$pr_branch" ]; then
curl -sf -X DELETE -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/repos/${FORGE_OPS_REPO}/git/branches/${pr_branch}" >/dev/null 2>&1 \
|| log "WARN: failed to delete branch ${pr_branch}"
fi
# Use Forgejo API to find open architect PRs
local response
response=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/repos/${FORGE_OPS_REPO}/pulls?state=open" 2>/dev/null) || return 1
# Check each open PR for architect markers
pr_number=$(printf '%s' "$response" | jq -r '.[] | select(.title | contains("architect:")) | .number' 2>/dev/null | head -1) || return 1
if [ -z "$pr_number" ]; then
return 1
fi
# Fetch PR body
# Remove in-progress label from the vision issue referenced in the PR
local pr_body
pr_body=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/repos/${FORGE_OPS_REPO}/pulls/${pr_number}" 2>/dev/null | jq -r '.body // empty') || return 1
"${FORGE_API}/repos/${FORGE_OPS_REPO}/pulls/${pr_num}" 2>/dev/null | jq -r '.body // ""') || pr_body=""
local vision_ref
vision_ref=$(printf '%s' "$pr_body" | grep -oE '#[0-9]+' | head -1 | tr -d '#') || vision_ref=""
if [ -n "$vision_ref" ]; then
# Look up in-progress label ID
local label_id
label_id=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/labels" 2>/dev/null | jq -r '.[] | select(.name == "in-progress") | .id' 2>/dev/null) || label_id=""
if [ -n "$label_id" ]; then
curl -sf -X DELETE -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/issues/${vision_ref}/labels/${label_id}" >/dev/null 2>&1 \
|| log "WARN: failed to remove in-progress label from issue #${vision_ref}"
fi
fi
# Journal the rejection via .profile (if available)
profile_write_journal "architect-reject-${pr_num}" \
"Sprint PR #${pr_num} rejected" \
"rejected: ${reason}" "" || true
# Clean up per-PR session file
rm -f "${SID_DIR}/pr-${pr_num}.sid"
log "REJECT handled for PR #${pr_num}"
}
# Detect answers on a PR in questions phase.
# Returns answer text via stdout, empty if no answers found.
# Args: pr_number
fetch_pr_answers() {
local pr_num="$1"
# Get PR body to check for Design forks section
local pr_body
pr_body=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/repos/${FORGE_OPS_REPO}/pulls/${pr_num}" 2>/dev/null | jq -r '.body // ""') || return 1
# Check for `## Design forks` section (added by #101 after ACCEPT)
if ! printf '%s' "$pr_body" | grep -q "## Design forks"; then
return 1
fi
# Check for question comments (Q1:, Q2:, etc.)
# Use jq to extract body text before grepping (handles JSON escaping properly)
local comments
comments=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/repos/${FORGE_OPS_REPO}/issues/${pr_number}/comments" 2>/dev/null) || return 1
# Fetch comments and look for answer patterns (Q1: A, Q2: B, etc.)
local comments_json
comments_json=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/repos/${FORGE_OPS_REPO}/issues/${pr_num}/comments" 2>/dev/null) || return 1
if ! printf '%s' "$comments" | jq -r '.[].body // empty' | grep -qE 'Q[0-9]+:'; then
return 1
# Find the most recent comment containing answer patterns
local answer_comment
answer_comment=$(printf '%s' "$comments_json" | jq -r '
[.[] | select(.body | test("Q[0-9]+:\\s*[A-Da-d]"))] | last | .body // empty
' 2>/dev/null) || answer_comment=""
if [ -n "$answer_comment" ]; then
printf '%s' "$answer_comment"
return 0
fi
# PR is in questions phase
log "Detected PR #${pr_number} in questions-awaiting-answers phase"
return 0
return 1
}
# ── Sub-issue existence check ────────────────────────────────────────────
@ -268,41 +350,189 @@ if [ "${vision_count:-0}" -eq 0 ]; then
fi
fi
# Check 2: Skip if already at max open pitches (3), unless there are responses to process
open_arch_prs=$(curl -sf -H "Authorization: token $FORGE_TOKEN" \
"${FORGE_API}/repos/${FORGE_OPS_REPO}/pulls?state=open&limit=10" 2>/dev/null | jq '[.[] | select(.title | startswith("architect:"))] | length') || open_arch_prs=0
if [ "${open_arch_prs:-0}" -ge 3 ]; then
# Check if any open architect PRs have ACCEPT/REJECT responses that need processing
has_responses=false
pr_numbers=$(curl -sf -H "Authorization: token $FORGE_TOKEN" \
"${FORGE_API}/repos/${FORGE_OPS_REPO}/pulls?state=open&limit=10" 2>/dev/null | jq -r '.[] | select(.title | startswith("architect:")) | .number') || pr_numbers=""
for pr_num in $pr_numbers; do
comments=$(curl -sf -H "Authorization: token $FORGE_TOKEN" \
"${FORGE_API}/repos/${FORGE_OPS_REPO}/issues/${pr_num}/comments" 2>/dev/null) || continue
if printf '%s' "$comments" | jq -r '.[].body // empty' | grep -qE '(ACCEPT|REJECT):'; then
has_responses=true
break
fi
done
if [ "$has_responses" = false ]; then
log "already 3 open architect PRs with no responses to process — skipping"
exit 0
fi
log "3 open architect PRs found but responses detected — processing"
# Track that we have responses to process (even if pitch_budget=0)
has_responses_to_process=true
fi
# ── Design phase: process existing architect PRs (bash-driven) ────────────
# Bash reads the reviews API and handles state transitions deterministically.
# Model is only invoked for research (ACCEPT) and answer processing.
# ── Preflight: Select vision issues for pitching ──────────────────────────
# This logic is also documented in formulas/run-architect.toml preflight step
# Get all open vision issues
vision_issues_json=$(curl -sf -H "Authorization: token $FORGE_TOKEN" \
"${FORGE_API}/issues?labels=vision&state=open&limit=100" 2>/dev/null) || vision_issues_json='[]'
# Get open architect PRs to skip issues they reference
open_arch_prs_json=$(curl -sf -H "Authorization: token $FORGE_TOKEN" \
"${FORGE_API}/repos/${FORGE_OPS_REPO}/pulls?state=open&limit=10" 2>/dev/null) || open_arch_prs_json='[]'
open_arch_prs=$(printf '%s' "$open_arch_prs_json" | jq '[.[] | select(.title | startswith("architect:"))] | length') || open_arch_prs=0
# Track whether we processed any responses (to decide if pitching is needed)
processed_responses=false
# Iterate over open architect PRs and handle each based on review state
arch_pr_data=$(printf '%s' "$open_arch_prs_json" | jq -r '.[] | select(.title | startswith("architect:")) | "\(.number)\t\(.head.ref // "")"' 2>/dev/null) || arch_pr_data=""
while IFS=$'\t' read -r pr_num pr_branch; do
[ -z "$pr_num" ] && continue
log "Checking PR #${pr_num} (branch: ${pr_branch})"
# First check: is this PR in the answers phase (questions posted, answers received)?
answer_text=""
answer_text=$(fetch_pr_answers "$pr_num") || true
if [ -n "$answer_text" ]; then
# ── Answers received: resume saved session with answers injected ──
log "Answers detected on PR #${pr_num} — resuming design session"
processed_responses=true
pr_sid_file="${SID_DIR}/pr-${pr_num}.sid"
RESUME_ARGS=()
if [ -f "$pr_sid_file" ]; then
RESUME_SESSION=$(cat "$pr_sid_file")
RESUME_ARGS=(--resume "$RESUME_SESSION")
log "Resuming session ${RESUME_SESSION:0:12}... for answer processing"
else
log "No saved session for PR #${pr_num} — starting fresh for answers"
fi
# Build answer-processing prompt with answers injected
# shellcheck disable=SC2034
SID_FILE="$pr_sid_file"
ANSWER_PROMPT="You are the architect agent for ${FORGE_REPO}. You previously researched a sprint and posted design questions on PR #${pr_num}.
Human answered the design fork questions. Parse the answers and file concrete sub-issues.
## Human answers
${answer_text}
## Project context
${CONTEXT_BLOCK}
${GRAPH_SECTION}
$(formula_lessons_block)
## Instructions
1. Parse each answer (e.g. Q1: A, Q2: C)
2. Read the sprint spec from the PR branch
3. Look up the backlog label ID on the disinto repo:
GET ${FORGE_API}/labels — find label with name 'backlog'
4. Generate final sub-issues based on answers:
- Each sub-issue uses the appropriate issue template
- Fill all template fields (problem, solution, affected files max 3, acceptance criteria max 5, dependencies)
- File via Forgejo API on the disinto repo (not ops repo)
- MUST include 'labels' with backlog label ID in create-issue request
- Include 'Decomposed from #<vision_issue_number>' in each issue body
5. Comment on PR #${pr_num}: 'Sprint filed: #N, #N, #N'
6. Merge the PR via: POST ${FORGE_API}/repos/${FORGE_OPS_REPO}/pulls/${pr_num}/merge with body {\"Do\":\"merge\"}
${PROMPT_FOOTER}"
# Create worktree if not already set up
if [ ! -d "$WORKTREE" ]; then
formula_worktree_setup "$WORKTREE"
fi
export CLAUDE_MODEL="sonnet"
agent_run "${RESUME_ARGS[@]}" --worktree "$WORKTREE" "$ANSWER_PROMPT"
# Restore SID_FILE to default
# shellcheck disable=SC2034 # consumed by agent-sdk.sh
SID_FILE="/tmp/architect-session-${PROJECT_NAME}.sid"
log "Answer processing complete for PR #${pr_num}"
continue
fi
# Second check: fetch review decision (ACCEPT/REJECT/NONE)
# Sets REVIEW_DECISION and REVIEW_GUIDANCE global variables
fetch_pr_review_decision "$pr_num"
decision="$REVIEW_DECISION"
guidance="$REVIEW_GUIDANCE"
case "$decision" in
REJECT)
# ── REJECT: handled entirely in bash ──
handle_reject "$pr_num" "$pr_branch" "$guidance"
processed_responses=true
# Decrement open PR count (PR is now closed)
open_arch_prs=$((open_arch_prs - 1))
;;
ACCEPT)
# ── ACCEPT: invoke model with human guidance for research + questions ──
log "ACCEPT detected on PR #${pr_num} with guidance: ${guidance:-(none)}"
processed_responses=true
# Build human guidance block
GUIDANCE_BLOCK=""
if [ -n "$guidance" ]; then
GUIDANCE_BLOCK="## Human guidance (from sprint PR review)
${guidance}
The architect MUST factor this guidance into design fork identification
and question formulation — if the human specifies an approach, that approach
should be the default fork, and questions should refine it rather than
re-evaluate it."
fi
# Build research + questions prompt
RESEARCH_PROMPT="You are the architect agent for ${FORGE_REPO}. A sprint pitch on PR #${pr_num} has been ACCEPTED by a human reviewer.
Your task: research the codebase deeply, identify design forks, and formulate questions.
${GUIDANCE_BLOCK}
## Project context
${CONTEXT_BLOCK}
${GRAPH_SECTION}
${SCRATCH_CONTEXT}
$(formula_lessons_block)
## Instructions
1. Read the sprint spec from PR #${pr_num} on the ops repo (branch: ${pr_branch})
2. Research the codebase deeply:
- Read all files mentioned in the sprint spec
- Search for existing interfaces that could be reused
- Check what infrastructure already exists
3. Identify design forks — multiple valid implementation approaches
4. Formulate multiple-choice questions (Q1, Q2, Q3...)
5. Update the sprint spec file on the PR branch:
- Add '## Design forks' section with fork options
- Add '## Proposed sub-issues' section with concrete issues per fork path
- Use Forgejo API: PUT ${FORGE_API}/repos/${FORGE_OPS_REPO}/contents/<path> with branch ${pr_branch}
6. Update the PR body to include the Design forks section (required for answer detection):
- PATCH ${FORGE_API}/repos/${FORGE_OPS_REPO}/pulls/${pr_num}
- Body: {\"body\": \"<existing PR body + Design forks section>\"}
- The PR body MUST contain '## Design forks' after this step
7. Comment on PR #${pr_num} with the questions formatted as multiple choice:
- POST ${FORGE_API}/repos/${FORGE_OPS_REPO}/issues/${pr_num}/comments
${SCRATCH_INSTRUCTION}
${PROMPT_FOOTER}"
# Use per-PR session file for stateful resumption
pr_sid_file="${SID_DIR}/pr-${pr_num}.sid"
# shellcheck disable=SC2034
SID_FILE="$pr_sid_file"
# Create worktree if not already set up
if [ ! -d "$WORKTREE" ]; then
formula_worktree_setup "$WORKTREE"
fi
export CLAUDE_MODEL="sonnet"
agent_run --worktree "$WORKTREE" "$RESEARCH_PROMPT"
log "Research + questions posted for PR #${pr_num}, session saved: ${pr_sid_file}"
# Restore SID_FILE to default
# shellcheck disable=SC2034 # consumed by agent-sdk.sh
SID_FILE="/tmp/architect-session-${PROJECT_NAME}.sid"
;;
NONE)
log "PR #${pr_num} — no response yet, skipping"
;;
esac
done <<< "$arch_pr_data"
# ── Preflight: Select vision issues for pitching ──────────────────────────
# Recalculate open PR count after handling responses (REJECTs reduce count)
# Re-fetch if we processed any responses (PR count may have changed)
if [ "$processed_responses" = true ]; then
open_arch_prs_json=$(curl -sf -H "Authorization: token $FORGE_TOKEN" \
"${FORGE_API}/repos/${FORGE_OPS_REPO}/pulls?state=open&limit=10" 2>/dev/null) || open_arch_prs_json='[]'
open_arch_prs=$(printf '%s' "$open_arch_prs_json" | jq '[.[] | select(.title | startswith("architect:"))] | length') || open_arch_prs=0
fi
# Build list of vision issues that already have open architect PRs
declare -A _arch_vision_issues_with_open_prs
@ -317,6 +547,10 @@ while IFS= read -r pr_num; do
done <<< "$(printf '%s' "$pr_body" | grep -oE '#[0-9]+' | tr -d '#' | sort -u)"
done <<< "$(printf '%s' "$open_arch_prs_json" | jq -r '.[] | select(.title | startswith("architect:")) | .number')"
# Get all open vision issues
vision_issues_json=$(curl -sf -H "Authorization: token $FORGE_TOKEN" \
"${FORGE_API}/issues?labels=vision&state=open&limit=100" 2>/dev/null) || vision_issues_json='[]'
# Get issues with in-progress label
in_progress_issues=$(curl -sf -H "Authorization: token $FORGE_TOKEN" \
"${FORGE_API}/issues?labels=in-progress&state=open&limit=100" 2>/dev/null | jq -r '.[].number' 2>/dev/null) || in_progress_issues=""
@ -334,7 +568,7 @@ while IFS= read -r vision_issue; do
vision_issue_count=$((vision_issue_count + 1))
# Skip if pitch budget exhausted
if [ ${#ARCHITECT_TARGET_ISSUES[@]} -ge $pitch_budget ]; then
if [ "${pitch_budget}" -le 0 ] || [ ${#ARCHITECT_TARGET_ISSUES[@]} -ge "$pitch_budget" ]; then
log "Pitch budget exhausted (${#ARCHITECT_TARGET_ISSUES[@]}/${pitch_budget})"
break
fi
@ -368,43 +602,68 @@ while IFS= read -r vision_issue; do
log "Selected vision issue #${vision_issue} for pitching"
done <<< "$vision_issue_nums"
# If no issues selected, signal done
# BUT: if we have responses to process (from Check 2), still run agent to handle them
# If no issues selected and no responses processed, signal done
if [ ${#ARCHITECT_TARGET_ISSUES[@]} -eq 0 ]; then
if [ "${has_responses_to_process:-false}" = "true" ]; then
log "No new pitches, but responses detected — processing existing PRs"
if [ "$processed_responses" = true ]; then
log "No new pitches needed — responses already processed"
else
log "No vision issues available for pitching (all have open PRs, sub-issues, or merged sprint PRs) — signaling PHASE:done"
# Signal PHASE:done by writing to phase file if it exists
if [ -f "/tmp/architect-${PROJECT_NAME}.phase" ]; then
echo "PHASE:done" > "/tmp/architect-${PROJECT_NAME}.phase"
fi
fi
# Signal PHASE:done by writing to phase file if it exists
if [ -f "/tmp/architect-${PROJECT_NAME}.phase" ]; then
echo "PHASE:done" > "/tmp/architect-${PROJECT_NAME}.phase"
fi
if [ ${#ARCHITECT_TARGET_ISSUES[@]} -eq 0 ] && [ "$processed_responses" = false ]; then
exit 0
fi
# If responses were processed but no pitches, still clean up and exit
if [ ${#ARCHITECT_TARGET_ISSUES[@]} -eq 0 ]; then
rm -f "$SCRATCH_FILE"
rm -f "${SCRATCH_FILE_PREFIX}"-*.md
profile_write_journal "architect-run" "Architect run $(date -u +%Y-%m-%d)" "complete" "" || true
log "--- Architect run done ---"
exit 0
fi
fi
log "Selected ${#ARCHITECT_TARGET_ISSUES[@]} vision issue(s) for pitching: ${ARCHITECT_TARGET_ISSUES[*]}"
# ── Run agent ─────────────────────────────────────────────────────────────
export CLAUDE_MODEL="sonnet"
# ── Pitch prompt: research + PR creation (model handles pitching only) ────
# 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.
# Determine whether to resume session:
# - If answers detected (PR in questions phase), resume prior session to preserve
# codebase context from research/questions run
# - Otherwise, start fresh (new pitch or PR not in questions phase)
RESUME_ARGS=()
if detect_questions_phase && [ -f "$SID_FILE" ]; then
RESUME_SESSION=$(cat "$SID_FILE")
RESUME_ARGS=(--resume "$RESUME_SESSION")
log "Resuming session from questions phase run: ${RESUME_SESSION:0:12}..."
elif ! detect_questions_phase; then
log "PR not in questions phase — starting fresh session"
elif [ ! -f "$SID_FILE" ]; then
log "No session ID found for questions phase — starting fresh session"
Your role: strategic decomposition of vision issues into development sprints.
Propose sprints via PRs on the ops repo.
## Target vision issues for pitching
${ARCHITECT_TARGET_ISSUES[*]}
## 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 (if not already set up from design phase) ─────────────
if [ ! -d "$WORKTREE" ]; then
formula_worktree_setup "$WORKTREE"
fi
agent_run "${RESUME_ARGS[@]}" --worktree "$WORKTREE" "$PROMPT"
log "agent_run complete"
# ── Run agent for pitching ────────────────────────────────────────────────
export CLAUDE_MODEL="sonnet"
agent_run --worktree "$WORKTREE" "$PROMPT"
log "agent_run complete (pitching)"
# Clean up scratch files (legacy single file + per-issue files)
rm -f "$SCRATCH_FILE"

View file

@ -3,21 +3,24 @@
# 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, handle existing PRs, select up to 3 target issues
# This formula orchestrates the architect agent's pitching workflow:
# Step 1: Preflight — validate prerequisites, select up to 3 target issues
# Step 2: Research + pitch — analyze codebase and write sprint pitch (loop over selected issues)
# Step 3: Sprint PR creation with questions (issue #101) (one PR per pitch)
# Step 4: Answer parsing + sub-issue filing (issue #102)
# Step 3: Sprint PR creation (one PR per pitch)
#
# The architect pitches up to 3 sprints per run when multiple vision issues
# exist. Existing PRs (accept/reject) are handled first, then new pitches
# are created with remaining budget. Max 3 open architect PRs at any time.
# Design phase (ACCEPT/REJECT handling, questions, answer processing) is
# orchestrated by bash in architect-run.sh — not by this formula.
# See architect-run.sh for the bash-driven state machine:
# - Bash reads reviews API for ACCEPT/REJECT detection (deterministic)
# - REJECT handled entirely in bash (close PR, delete branch, journal)
# - ACCEPT: bash invokes model with human guidance injected
# - Answers: bash resumes saved session with answers injected
#
# AGENTS.md maintenance is handled by the gardener (#246).
name = "run-architect"
description = "Architect: strategic decomposition of vision into sprints"
version = 1
version = 2
model = "opus"
[context]
@ -27,140 +30,43 @@ files = ["VISION.md", "AGENTS.md"]
[[steps]]
id = "preflight"
title = "Preflight: validate prerequisites, handle existing PRs, select up to 3 target issues"
title = "Preflight: validate prerequisites, select up to 3 target issues"
description = """
This step performs preflight checks, handles existing architect PRs, and selects
up to 3 vision issues for pitching.
This step is performed by bash in architect-run.sh before the model is invoked.
Actions:
Bash handles:
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 FIRST (see Capability B below)
4. Check for open architect PRs on ops repo
5. Process existing PRs via bash-driven design phase (ACCEPT/REJECT/answers)
6. After handling existing PRs, count remaining open architect PRs
7. Select up to (3 - open_architect_pr_count) vision issues for new pitches
8. If no vision issues, signal PHASE:done
## Multi-pitch selection (up to 3 per run)
After handling existing PRs (accept/reject/answer parsing), determine how many
new pitches can be created:
After handling existing PRs, determine how many new pitches can be created:
pitch_budget = 3 - <number of open architect PRs remaining after handling>
For each available pitch slot:
1. From the vision issues list, skip any issue that already has an open architect PR
(match by checking if any open architect PR body references the vision issue number)
2. Skip any issue that already has the `in-progress` label
3. Check for existing sub-issues filed from this vision issue (see Sub-issue existence check below)
4. Check for merged sprint PRs referencing this vision issue (see Merged sprint PR check below)
5. From remaining candidates, pick the most unblocking issue first (fewest open
dependencies, or earliest created if tied)
3. Check for existing sub-issues filed from this vision issue
4. Check for merged sprint PRs referencing this vision issue
5. From remaining candidates, pick the most unblocking issue first
6. Add to ARCHITECT_TARGET_ISSUES array
### Sub-issue existence check
Before selecting a vision issue for pitching, check if it already has sub-issues:
1. Search for issues whose body contains 'Decomposed from #N' where N is the vision issue number
2. Also check for issues filed by architect-bot that reference the vision issue number
3. If any sub-issues are open (state != 'closed'), skip this vision issue it's already being worked on
4. If all sub-issues are closed, the vision issue may be ready for a new sprint (next phase)
API calls:
- GET /repos/{owner}/{repo}/issues?labels=vision&state=open fetch vision issues
- GET /repos/{owner}/{repo}/issues search all issues for 'Decomposed from #N' pattern
- Check each issue's state field to determine if open or closed
### Merged sprint PR check
Before selecting a vision issue for pitching, check for merged architect PRs:
1. Search for merged PRs on the ops repo where the body references the vision issue number
2. Use: GET /repos/{owner}/{repo}/pulls?state=closed (then filter for merged ones)
3. If a merged PR references the vision issue, decomposition already happened skip this vision issue
API calls:
- GET /repos/{owner}/{repo}/pulls?state=closed fetch closed PRs from ops repo
- Check PR body for references to the vision issue number (e.g., "refs #N" or "#N")
Skip conditions:
- If no vision issues are found, signal PHASE:done
- If pitch_budget <= 0 (already 3 open architect PRs), skip pitching only handle existing PRs
- If pitch_budget <= 0 (already 3 open architect PRs), skip pitching
- If all vision issues already have open architect PRs, signal PHASE:done
- If all vision issues have open sub-issues, skip pitching decomposition already in progress
- If all vision issues have merged sprint PRs, skip pitching decomposition already completed
- If all vision issues have open sub-issues, skip pitching
- If all vision issues have merged sprint PRs, skip pitching
Output:
- Sets ARCHITECT_TARGET_ISSUES as a JSON array of issue numbers to pitch (up to 3)
- 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. Check for human response reviews first, then comments.
**Step 1 Check PR reviews (Forgejo review UI):**
Fetch reviews via Forgejo API:
```
GET /repos/{owner}/{repo}/pulls/{index}/reviews
```
Scan the response array for reviews with non-bot authors:
- `state: "APPROVED"` treat as **ACCEPT**. Save the review `body` field as
human guidance text it often contains critical architectural constraints
(e.g. "the caddy is on another host, use a dispatchable container with SSH key as secret").
- `state: "REQUEST_CHANGES"` treat as **REJECT** with the review `body` as the reason.
- Other states (COMMENT, PENDING) ignore, not a decision.
If multiple reviews exist, use the most recent one (last in array).
**Step 2 Check PR comments (fallback for backwards compat):**
Fetch comments on each open architect PR via Forgejo API.
Scan for ACCEPT/REJECT text as described below.
**Precedence:** If both a review decision and a comment decision exist,
the review takes precedence.
2. Act on the human response:
**ACCEPT** (case insensitive, from review APPROVED or comment text): Human wants to proceed
- Extract the human guidance text:
- From review APPROVED: use the review `body` field (may be empty)
- From comment: use any text after ACCEPT (e.g. "ACCEPT — use SSH approach" "use SSH approach")
- Architect does deep research for design forks (same as #100 research but now identifying decision points).
If human guidance text is non-empty, prepend it to the research context:
## Human guidance (from sprint PR review)
<the extracted review body or comment text>
The architect MUST factor this guidance into design fork identification
and question formulation e.g. if the human specifies an approach,
that approach should be the default fork, and questions should refine
it rather than re-evaluate it.
- 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, from review REQUEST_CHANGES or comment text;
for reviews the reason is the review body, for comments the reason follows the 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
- Remove `in-progress` label from the vision issue on the disinto repo:
- Look up the `in-progress` label ID via `GET /repos/{owner}/{repo}/labels`
- Remove the label via `DELETE /repos/{owner}/{repo}/issues/{vision_issue_number}/labels/{label_id}`
- 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]]
@ -185,7 +91,7 @@ Actions:
- What are the risks (breaking changes, security implications, integration complexity)?
- Is this mostly gluecode or greenfield?
3. Write sprint pitch to a per-issue scratch file for PR creation step (#101):
3. Write sprint pitch to a per-issue scratch file for PR creation step:
- File path: /tmp/architect-{project}-scratch-{issue_number}.md
# Sprint pitch: <name>
@ -218,18 +124,18 @@ decision for the human. Questions come only after acceptance.
Output:
- Writes one scratch file per vision issue: /tmp/architect-{project}-scratch-{issue_number}.md
- Each pitch serves as input for sprint PR creation step (#101)
- Each pitch serves as input for sprint PR creation step
- If ARCHITECT_TARGET_ISSUES is empty (budget exhausted or no candidates), skip this step
"""
[[steps]]
id = "sprint_pr_creation"
title = "Sprint PR creation with questions (issue #101)"
title = "Sprint PR creation (one PR per pitch)"
description = """
This step creates a PR on the ops repo for EACH sprint pitch produced in step 2.
One PR per vision issue loop over all scratch files.
## Capability A: Create pitch PRs (from research output)
## Create pitch PRs (from research output)
For each vision issue in ARCHITECT_TARGET_ISSUES that produced a scratch file
(/tmp/architect-{project}-scratch-{issue_number}.md):
@ -276,7 +182,6 @@ For each vision issue in ARCHITECT_TARGET_ISSUES that produced a scratch file
- Look up the `in-progress` label ID via `GET /repos/{owner}/{repo}/labels`
- Add the label via `POST /repos/{owner}/{repo}/issues/{vision_issue_number}/labels`
Body: `{"labels": [<in-progress-label-id>]}`
- This makes the vision issue visible as actively worked on
5. After creating all PRs, signal PHASE:done
@ -306,7 +211,6 @@ Body: {"title": "architect: <sprint summary>", "body": "<markdown-text>", "head"
- The `body` field must contain **plain markdown text** (the raw content from the scratch file)
- Do NOT JSON-encode or escape the body pass it as a JSON string value
- Newlines and markdown formatting (headings, lists, etc.) must be preserved as-is
- Example: if the sprint pitch contains `## What this enables`, the PR body should render this as a markdown heading, not as literal `## What this enables` text
### Close PR
```
@ -335,122 +239,3 @@ Body: {"labels": [<label-id>]}
DELETE /repos/{owner}/{repo}/issues/{index}/labels/{label-id}
```
"""
[[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.
## Session resumption
When processing answers, the architect resumes the session from the research/questions
run (step 2) to preserve codebase context. This ensures Claude has full understanding
of dispatcher.sh, vault.sh, branch-protection.sh, and all formulas when filing
sub-issues, resulting in more specific file references and implementation details.
The session ID is persisted to `$SID_FILE` after the research/questions run. On
answer_parsing runs, if the PR is in the questions phase, the session is resumed
instead of starting fresh.
## 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. Look up the `backlog` label ID on the disinto repo:
- `GET /repos/{owner}/{repo}/labels` find the label with `name: "backlog"` and note its `id`
- This ID is required for the issue creation API call below
4. 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)
- **MUST include `"labels": [<backlog-label-id>]`** in the create-issue request body
so dev-poll picks them up
5. Comment on PR: "Sprint filed: #N, #N, #N"
6. 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 (with backlog label — required)
```
POST /repos/{owner}/{repo}/issues
Body: {
"title": "<issue title>",
"body": "<issue body with template fields>",
"labels": [<backlog-label-id>]
}
```
### 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 labels (look up label IDs by name)
```
GET /repos/{owner}/{repo}/labels
```
### Add label to issue
```
POST /repos/{owner}/{repo}/issues/{index}/labels
Body: {"labels": [<label-id>]}
```
### Remove label from issue
```
DELETE /repos/{owner}/{repo}/issues/{index}/labels/{label-id}
```
"""