Compare commits

..

2 commits

Author SHA1 Message Date
Claude
bcc8397e52 ci: retrigger smoke-init pipeline
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-28 15:36:46 +00:00
Claude
e74723982d fix: fix: smoke-init should only run on pull_request events, not push (#21)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/pr/smoke-init Pipeline failed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:26:08 +00:00
99 changed files with 3196 additions and 6923 deletions

View file

@ -26,8 +26,8 @@ FORGE_GARDENER_TOKEN= # [SECRET] gardener-bot API token
FORGE_VAULT_TOKEN= # [SECRET] vault-bot API token FORGE_VAULT_TOKEN= # [SECRET] vault-bot API token
FORGE_SUPERVISOR_TOKEN= # [SECRET] supervisor-bot API token FORGE_SUPERVISOR_TOKEN= # [SECRET] supervisor-bot API token
FORGE_PREDICTOR_TOKEN= # [SECRET] predictor-bot API token FORGE_PREDICTOR_TOKEN= # [SECRET] predictor-bot API token
FORGE_ARCHITECT_TOKEN= # [SECRET] architect-bot API token 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,architect-bot FORGE_BOT_USERNAMES=dev-bot,review-bot,planner-bot,gardener-bot,vault-bot,supervisor-bot,predictor-bot,action-bot
# ── Backwards compatibility ─────────────────────────────────────────────── # ── Backwards compatibility ───────────────────────────────────────────────
# If CODEBERG_TOKEN is set but FORGE_TOKEN is not, env.sh falls back to # 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) ──────────────────────── # ── Vault-only secrets (DO NOT put these in .env) ────────────────────────
# These tokens grant access to external systems (GitHub, ClawHub, deploy targets). # 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 # 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. # 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 # (deploy keys) — SSH keys for deployment targets
# #
# To manage vault secrets: disinto secrets edit-vault # 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 ────────────────────────────────────────────── # ── Project-specific secrets ──────────────────────────────────────────────
# Store all project secrets here so formulas reference env vars, never hardcode. # Store all project secrets here so formulas reference env vars, never hardcode.

6
.gitignore vendored
View file

@ -22,9 +22,3 @@ metrics/supervisor-metrics.jsonl
.DS_Store .DS_Store
dev/ci-fixes-*.json dev/ci-fixes-*.json
gardener/dust.jsonl 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/

View file

@ -84,7 +84,7 @@ while IFS= read -r -d '' f; do
printf 'FAIL [syntax] %s\n' "$f" printf 'FAIL [syntax] %s\n' "$f"
FAILED=1 FAILED=1
fi 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" echo "syntax check done"
# ── 2. Function-resolution check ───────────────────────────────────────────── # ── 2. Function-resolution check ─────────────────────────────────────────────
@ -210,10 +210,15 @@ check_script review/review-poll.sh
check_script planner/planner-run.sh lib/agent-session.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/supervisor-poll.sh
check_script supervisor/update-prompt.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
check_script supervisor/supervisor-run.sh check_script supervisor/supervisor-run.sh
check_script supervisor/preflight.sh check_script supervisor/preflight.sh
check_script predictor/predictor-run.sh check_script predictor/predictor-run.sh
check_script architect/architect-run.sh
echo "function resolution check done" echo "function resolution check done"

View file

@ -179,17 +179,10 @@ def collect_findings(root):
Returns ``(ap_hits, dup_groups)`` with file paths relative to *root*. Returns ``(ap_hits, dup_groups)`` with file paths relative to *root*.
""" """
root = Path(root) root = Path(root)
# Skip architect scripts for duplicate detection (stub formulas, see #99) sh_files = sorted(
EXCLUDED_SUFFIXES = ("architect/architect-run.sh",) p for p in root.rglob("*.sh") if ".git" not in p.parts
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))
ap_hits = check_anti_patterns(sh_files) ap_hits = check_anti_patterns(sh_files)
dup_groups = check_duplicates(sh_files) dup_groups = check_duplicates(sh_files)
@ -245,65 +238,10 @@ def print_duplicates(groups, label=""):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def main() -> int: def main() -> int:
# Skip architect scripts for duplicate detection (stub formulas, see #99) sh_files = sorted(
EXCLUDED_SUFFIXES = ("architect/architect-run.sh",) p for p in Path(".").rglob("*.sh") if ".git" not in p.parts
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)",
}
if not sh_files: if not sh_files:
print("No .sh files found.") print("No .sh files found.")
return 0 return 0
@ -338,13 +276,8 @@ def main() -> int:
# Duplicate diff: key by content hash # Duplicate diff: key by content hash
base_dup_hashes = {g[0] for g in base_dups} 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]
new_dups = [ pre_dups = [g for g in cur_dups if g[0] in base_dup_hashes]
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]
# Report pre-existing as info # Report pre-existing as info
if pre_ap or pre_dups: if pre_ap or pre_dups:

View file

@ -1,18 +1,45 @@
# .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: when:
- event: pull_request - event: pull_request
path: path:
- "bin/disinto" - "bin/disinto"
- "lib/load-project.sh" - "lib/load-project.sh"
- "lib/env.sh" - "tests/smoke-init.sh"
- "tests/**"
- ".woodpecker/smoke-init.yml" - ".woodpecker/smoke-init.yml"
- "docker/**"
- event: push
branch: main
path:
- "bin/disinto"
- "lib/load-project.sh"
- "tests/smoke-init.sh"
- ".woodpecker/smoke-init.yml"
- "docker/**"
steps: steps:
- name: smoke-init - name: smoke-init
image: python:3-alpine image: codeberg.org/forgejo/forgejo:11.0
environment:
SMOKE_FORGE_URL: http://localhost:3000
commands: commands:
- apk add --no-cache bash curl jq git coreutils # Install test dependencies (Alpine-based image)
- python3 tests/mock-forgejo.py & echo $! > /tmp/mock-forgejo.pid - apk add --no-cache bash curl jq python3 git >/dev/null 2>&1
- sleep 2 # 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 - bash tests/smoke-init.sh
- kill $(cat /tmp/mock-forgejo.pid) 2>/dev/null || true

View file

@ -1,19 +1,12 @@
<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 --> <!-- last-reviewed: f32707ba659de278a3af434e3549fb8a8dce9d3a -->
# Disinto — Agent Instructions # Disinto — Agent Instructions
## What this repo is ## What this repo is
Disinto is an autonomous code factory. It manages seven agents (dev, review, Disinto is an autonomous code factory. It manages eight agents (dev, review,
gardener, supervisor, planner, predictor, architect) that pick up issues from gardener, supervisor, planner, predictor, action, vault) that pick up issues from forge,
forge, implement them, review PRs, plan from the vision, and keep the system implement them, review PRs, plan from the vision, gate dangerous actions, and
healthy — all via cron and `claude -p`. The dispatcher executes formula-based keep the system healthy — all via cron and `claude -p`.
operational tasks.
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 `disinto-factory/SKILL.md` for setup.
@ -29,9 +22,9 @@ disinto/ (code repo)
├── supervisor/ supervisor-run.sh — formula-driven health monitoring (cron wrapper) ├── supervisor/ supervisor-run.sh — formula-driven health monitoring (cron wrapper)
│ preflight.sh — pre-flight data collection for supervisor formula │ preflight.sh — pre-flight data collection for supervisor formula
│ supervisor-poll.sh — legacy bash orchestrator (superseded) │ supervisor-poll.sh — legacy bash orchestrator (superseded)
├── architect/ architect-run.sh — strategic decomposition of vision into sprints ├── vault/ vault-poll.sh, vault-agent.sh, vault-fire.sh — action gating + procurement
├── vault/ vault-env.sh — shared env setup (vault redesign in progress, see #73-#77) ├── 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, pr-lifecycle.sh, issue-lifecycle.sh, worktree.sh, formula-session.sh, profile.sh, build-graph.py ├── lib/ env.sh, agent-session.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, build-graph.py
├── projects/ *.toml.example — templates; *.toml — local per-box config (gitignored) ├── projects/ *.toml.example — templates; *.toml — local per-box config (gitignored)
├── formulas/ Issue templates (TOML specs for multi-step agent tasks) ├── formulas/ Issue templates (TOML specs for multi-step agent tasks)
└── docs/ Protocol docs (PHASE-PROTOCOL.md, EVIDENCE-ARCHITECTURE.md) └── docs/ Protocol docs (PHASE-PROTOCOL.md, EVIDENCE-ARCHITECTURE.md)
@ -42,6 +35,9 @@ disinto-ops/ (ops repo — {project}-ops)
│ ├── approved/ approved vault items │ ├── approved/ approved vault items
│ ├── fired/ executed vault items │ ├── fired/ executed vault items
│ └── rejected/ rejected vault items │ └── rejected/ rejected vault items
├── journal/
│ ├── planner/ daily planning logs
│ └── supervisor/ operational health logs
├── knowledge/ shared agent knowledge + best practices ├── knowledge/ shared agent knowledge + best practices
├── evidence/ engagement data, experiment results ├── evidence/ engagement data, experiment results
├── portfolio.md addressables + observables ├── portfolio.md addressables + observables
@ -49,13 +45,10 @@ disinto-ops/ (ops repo — {project}-ops)
└── RESOURCES.md accounts, tokens (refs), infra inventory └── 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. > **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
## Agent .profile Model > 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.
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`.
## Tech stack ## Tech stack
@ -97,10 +90,8 @@ bash dev/phase-test.sh
| Supervisor | `supervisor/` | Health monitoring | [supervisor/AGENTS.md](supervisor/AGENTS.md) | | Supervisor | `supervisor/` | Health monitoring | [supervisor/AGENTS.md](supervisor/AGENTS.md) |
| Planner | `planner/` | Strategic planning | [planner/AGENTS.md](planner/AGENTS.md) | | Planner | `planner/` | Strategic planning | [planner/AGENTS.md](planner/AGENTS.md) |
| Predictor | `predictor/` | Infrastructure pattern detection | [predictor/AGENTS.md](predictor/AGENTS.md) | | Predictor | `predictor/` | Infrastructure pattern detection | [predictor/AGENTS.md](predictor/AGENTS.md) |
| Architect | `architect/` | Strategic decomposition | [architect/AGENTS.md](architect/AGENTS.md) | | Action | `action/` | Operational task execution | [action/AGENTS.md](action/AGENTS.md) |
| Vault | `vault/` | Action gating + resource procurement | [vault/AGENTS.md](vault/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.
See [lib/AGENTS.md](lib/AGENTS.md) for the full shared helper reference. See [lib/AGENTS.md](lib/AGENTS.md) for the full shared helper reference.
@ -117,13 +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 | | `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 | | `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) | | `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) | | `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) | | `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) |
| `vision` | Goal anchors — high-level objectives from VISION.md. | Planner, humans | | `vision` | Goal anchors — high-level objectives from VISION.md. | Planner, humans |
| `prediction/unreviewed` | Unprocessed prediction filed by predictor. | predictor-run.sh | | `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/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) | | `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 ### Dependency conventions
@ -168,12 +160,12 @@ Humans write these. Agents read and enforce them.
| ID | Decision | Rationale | | 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-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-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-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-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 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-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:** **Who enforces what:**
- **Gardener** checks open backlog issues against ADs during grooming; closes violations with a comment referencing the AD number. - **Gardener** checks open backlog issues against ADs during grooming; closes violations with a comment referencing the AD number.

View file

@ -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 cron (weekly) ──→ planner-poll.sh ← gap-analyse VISION.md, create backlog issues
└── claude -p: update AGENTS.md → create 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 ## Prerequisites
@ -93,6 +96,7 @@ crontab -e
# 3,13,23,33,43,53 * * * * /path/to/disinto/review/review-poll.sh # 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 # 6,16,26,36,46,56 * * * * /path/to/disinto/dev/dev-poll.sh
# 15 8 * * * /path/to/disinto/gardener/gardener-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 # 0 9 * * 1 /path/to/disinto/planner/planner-poll.sh
# 4. Verify # 4. Verify
@ -119,13 +123,16 @@ disinto/
│ └── best-practices.md # Gardener knowledge base │ └── best-practices.md # Gardener knowledge base
├── planner/ ├── planner/
│ ├── planner-poll.sh # Cron entry: weekly vision gap analysis │ ├── 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/
│ └── vault-env.sh # Shared env setup (vault redesign in progress, see #73-#77) │ ├── vault-poll.sh # Cron entry: process pending dangerous actions
├── docs/ │ ├── vault-agent.sh # Classifies and routes actions (claude -p)
│ └── VAULT.md # Vault PR workflow and branch protection documentation │ ├── vault-fire.sh # Executes an approved action
│ ├── vault-reject.sh # Marks an action as rejected
│ └── PROMPT.md # System prompt for vault agent
└── supervisor/ └── supervisor/
├── supervisor-poll.sh # Supervisor: health checks + claude -p ├── supervisor-poll.sh # Supervisor: health checks + claude -p
├── PROMPT.md # Supervisor's system prompt
├── update-prompt.sh # Self-learning: append to best-practices ├── update-prompt.sh # Self-learning: append to best-practices
└── best-practices/ # Progressive disclosure knowledge base └── best-practices/ # Progressive disclosure knowledge base
├── memory.md ├── memory.md
@ -146,9 +153,7 @@ disinto/
| **Review** | Every 10 min | Finds PRs without review, runs Claude-powered code review, approves or requests changes. | | **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. | | **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. | | **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** | 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. |
> **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.
## Design Principles ## Design Principles

34
action/AGENTS.md Normal file
View file

@ -0,0 +1,34 @@
<!-- last-reviewed: f32707ba659de278a3af434e3549fb8a8dce9d3a -->
# 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.

323
action/action-agent.sh Executable file
View file

@ -0,0 +1,323 @@
#!/usr/bin/env bash
# =============================================================================
# action-agent.sh — Synchronous action agent: SDK + shared libraries
#
# Synchronous bash loop using claude -p (one-shot invocation).
# No tmux sessions, no phase files — the bash script IS the state machine.
#
# Usage: ./action-agent.sh <issue-number> [project.toml]
#
# Flow:
# 1. Preflight: issue_check_deps(), memory guard, concurrency lock
# 2. Parse model from YAML front matter in issue body (custom model selection)
# 3. Worktree: worktree_create() for action isolation
# 4. Load formula from issue body
# 5. Build prompt: formula + prior non-bot comments (resume context)
# 6. agent_run(worktree, prompt) → Claude executes action, may push
# 7. If pushed: pr_walk_to_merge() from lib/pr-lifecycle.sh
# 8. Cleanup: worktree_cleanup(), issue_close()
#
# Action-specific (stays in runner):
# - YAML front matter parsing (model selection)
# - Bot username filtering for prior comments
# - Lifetime watchdog (MAX_LIFETIME=8h wall-clock cap)
# - Child process cleanup (docker compose, background jobs)
#
# From shared libraries:
# - Issue lifecycle: lib/issue-lifecycle.sh
# - Worktree: lib/worktree.sh
# - PR lifecycle: lib/pr-lifecycle.sh
# - Agent SDK: lib/agent-sdk.sh
#
# 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:-}}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
FACTORY_ROOT="$(dirname "$SCRIPT_DIR")"
# shellcheck source=../lib/env.sh
source "$FACTORY_ROOT/lib/env.sh"
# Use action-bot's own Forgejo identity (#747)
FORGE_TOKEN="${FORGE_ACTION_TOKEN:-${FORGE_TOKEN}}"
# shellcheck source=../lib/ci-helpers.sh
source "$FACTORY_ROOT/lib/ci-helpers.sh"
# shellcheck source=../lib/worktree.sh
source "$FACTORY_ROOT/lib/worktree.sh"
# shellcheck source=../lib/issue-lifecycle.sh
source "$FACTORY_ROOT/lib/issue-lifecycle.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"
BRANCH="action/issue-${ISSUE}"
WORKTREE="/tmp/action-${ISSUE}-$(date +%s)"
LOCKFILE="/tmp/action-agent-${ISSUE}.lock"
LOGFILE="${DISINTO_LOG_DIR}/action/action-poll-${PROJECT_NAME:-default}.log"
# shellcheck disable=SC2034 # consumed by agent-sdk.sh
SID_FILE="/tmp/action-session-${PROJECT_NAME:-default}-${ISSUE}.sid"
MAX_LIFETIME="${ACTION_MAX_LIFETIME:-28800}" # 8h default wall-clock cap
SESSION_START_EPOCH=$(date +%s)
log() {
printf '[%s] action#%s %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$ISSUE" "$*" >> "$LOGFILE"
}
# --- 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"
# 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
if [ "$exit_code" -ne 0 ]; then
worktree_preserve "$WORKTREE" "crashed (exit=$exit_code)"
else
worktree_cleanup "$WORKTREE"
fi
rm -f "$SID_FILE"
}
trap cleanup EXIT
# --- Memory guard ---
memory_guard 2000
# --- 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 (shared library) ---
if ! issue_check_deps "$ISSUE"; then
log "SKIP: issue #${ISSUE} blocked by: ${_ISSUE_BLOCKED_BY[*]}"
exit 0
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
# --- Determine git remote ---
cd "${PROJECT_REPO_ROOT}"
_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
# --- Create isolated worktree ---
log "creating worktree: ${WORKTREE}"
git fetch "${FORGE_REMOTE}" "${PRIMARY_BRANCH}" 2>/dev/null || true
if ! worktree_create "$WORKTREE" "$BRANCH"; then
log "ERROR: worktree creation failed"
exit 1
fi
log "worktree ready: ${WORKTREE}"
# --- Build prompt ---
PRIOR_SECTION=""
if [ -n "$PRIOR_COMMENTS" ]; then
PRIOR_SECTION="## Prior comments (resume context)
${PRIOR_COMMENTS}
"
fi
GIT_INSTRUCTIONS=$(build_phase_protocol_prompt "$BRANCH" "$FORGE_REMOTE")
PROMPT="You are an action agent. Your job is to execute the action formula
in the issue below.
## Issue #${ISSUE}: ${ISSUE_TITLE}
${ISSUE_BODY}
${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, post a comment explaining what
is needed and stop — the orchestrator will block the issue.
### Path A: If this action produces code changes (e.g. config updates, baselines):
- You are already in an isolated worktree at: ${WORKTREE}
- You are on branch: ${BRANCH}
- Make your changes, commit, and push: git push ${FORGE_REMOTE} ${BRANCH}
- **IMPORTANT:** The worktree is destroyed after completion. Push all
results before finishing — unpushed work will be lost.
### 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 finishing.
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.
${GIT_INSTRUCTIONS}"
# --- Wall-clock lifetime watchdog (background) ---
# Caps total run time independently of claude -p timeout. When the cap is
# hit the watchdog kills the main process, which triggers cleanup via trap.
_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 agent"
# Post summary comment on issue
local body="Action agent 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
kill $$ 2>/dev/null || true
}
_lifetime_watchdog &
LIFETIME_WATCHDOG_PID=$!
# --- Run agent ---
log "running agent (worktree: ${WORKTREE})"
agent_run --worktree "$WORKTREE" "$PROMPT"
log "agent_run complete"
# --- Detect if branch was pushed (Path A vs Path B) ---
PUSHED=false
# Check if remote branch exists
git fetch "${FORGE_REMOTE}" "$BRANCH" 2>/dev/null || true
if git rev-parse --verify "${FORGE_REMOTE}/${BRANCH}" >/dev/null 2>&1; then
PUSHED=true
fi
# Fallback: check local commits ahead of base
if [ "$PUSHED" = false ]; then
if git -C "$WORKTREE" log "${FORGE_REMOTE}/${PRIMARY_BRANCH}..${BRANCH}" --oneline 2>/dev/null | grep -q .; then
PUSHED=true
fi
fi
if [ "$PUSHED" = true ]; then
# --- Path A: code changes pushed — create PR and walk to merge ---
log "branch pushed — creating PR"
PR_NUMBER=""
PR_NUMBER=$(pr_create "$BRANCH" "action: ${ISSUE_TITLE}" \
"Closes #${ISSUE}
Automated action execution by action-agent.") || true
if [ -n "$PR_NUMBER" ]; then
log "walking PR #${PR_NUMBER} to merge"
pr_walk_to_merge "$PR_NUMBER" "$_AGENT_SESSION_ID" "$WORKTREE" || true
case "${_PR_WALK_EXIT_REASON:-}" in
merged)
log "PR #${PR_NUMBER} merged — closing issue"
issue_close "$ISSUE"
;;
*)
log "PR #${PR_NUMBER} not merged (reason: ${_PR_WALK_EXIT_REASON:-unknown})"
issue_block "$ISSUE" "pr_not_merged: ${_PR_WALK_EXIT_REASON:-unknown}"
;;
esac
else
log "ERROR: failed to create PR"
issue_block "$ISSUE" "pr_creation_failed"
fi
else
# --- Path B: no code changes — close issue directly ---
log "no branch pushed — closing issue (Path B)"
issue_close "$ISSUE"
fi
log "action-agent finished for issue #${ISSUE}"

75
action/action-poll.sh Executable file
View 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="${DISINTO_LOG_DIR}/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

View file

@ -1,65 +0,0 @@
<!-- last-reviewed: auto-generated -->
# 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

View file

@ -1,121 +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="$SCRIPT_DIR/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"
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; }
# ── Guards ────────────────────────────────────────────────────────────────
check_active architect
acquire_cron_lock "/tmp/architect-run.lock"
check_memory 2000
log "--- Architect run start ---"
# ── 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 ---"

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 --> <!-- last-reviewed: f32707ba659de278a3af434e3549fb8a8dce9d3a -->
# Dev Agent # Dev Agent
**Role**: Implement issues autonomously — write code, push branches, address **Role**: Implement issues autonomously — write code, push branches, address
@ -14,7 +14,7 @@ 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. check so approved PRs get merged even while a dev-agent session is active.
**Key files**: **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` with no active tmux session and no open PR — removes `in-progress`, adds `blocked` with a human-triage comment (requires maintainer review before re-queuing). - `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` (replaced `prediction/backlog` — that label no longer exists)
- `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/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-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 - `dev/phase-test.sh` — Integration test for the phase protocol

View file

@ -30,7 +30,6 @@ source "$(dirname "$0")/../lib/worktree.sh"
source "$(dirname "$0")/../lib/pr-lifecycle.sh" source "$(dirname "$0")/../lib/pr-lifecycle.sh"
source "$(dirname "$0")/../lib/mirrors.sh" source "$(dirname "$0")/../lib/mirrors.sh"
source "$(dirname "$0")/../lib/agent-sdk.sh" source "$(dirname "$0")/../lib/agent-sdk.sh"
source "$(dirname "$0")/../lib/formula-session.sh"
# Auto-pull factory code to pick up merged fixes before any logic runs # Auto-pull factory code to pick up merged fixes before any logic runs
git -C "$FACTORY_ROOT" pull --ff-only origin main 2>/dev/null || true git -C "$FACTORY_ROOT" pull --ff-only origin main 2>/dev/null || true
@ -41,7 +40,7 @@ REPO_ROOT="${PROJECT_REPO_ROOT}"
LOCKFILE="/tmp/dev-agent-${PROJECT_NAME:-default}.lock" LOCKFILE="/tmp/dev-agent-${PROJECT_NAME:-default}.lock"
STATUSFILE="/tmp/dev-agent-status-${PROJECT_NAME:-default}" STATUSFILE="/tmp/dev-agent-status-${PROJECT_NAME:-default}"
BRANCH="fix/issue-${ISSUE}" # Default; will be updated after FORGE_REMOTE is known BRANCH="fix/issue-${ISSUE}"
WORKTREE="/tmp/${PROJECT_NAME}-worktree-${ISSUE}" WORKTREE="/tmp/${PROJECT_NAME}-worktree-${ISSUE}"
SID_FILE="/tmp/dev-session-${PROJECT_NAME}-${ISSUE}.sid" SID_FILE="/tmp/dev-session-${PROJECT_NAME}-${ISSUE}.sid"
PREFLIGHT_RESULT="/tmp/dev-agent-preflight.json" PREFLIGHT_RESULT="/tmp/dev-agent-preflight.json"
@ -186,11 +185,7 @@ log "preflight passed"
# ============================================================================= # =============================================================================
# CLAIM ISSUE # CLAIM ISSUE
# ============================================================================= # =============================================================================
if ! issue_claim "$ISSUE"; then issue_claim "$ISSUE"
log "SKIP: failed to claim issue #${ISSUE} (already assigned to another agent)"
echo '{"status":"already_done","reason":"issue was claimed by another agent"}' > "$PREFLIGHT_RESULT"
exit 0
fi
CLAIMED=true CLAIMED=true
# ============================================================================= # =============================================================================
@ -263,19 +258,6 @@ FORGE_REMOTE="${FORGE_REMOTE:-origin}"
export FORGE_REMOTE export FORGE_REMOTE
log "forge remote: ${FORGE_REMOTE}" log "forge remote: ${FORGE_REMOTE}"
# Generate unique branch name per attempt to avoid collision with failed attempts
# Only apply when not in recovery mode (RECOVERY_MODE branch is already set from existing PR)
# First attempt: fix/issue-N, subsequent: fix/issue-N-1, fix/issue-N-2, etc.
if [ "$RECOVERY_MODE" = false ]; then
# Count only branches matching fix/issue-N, fix/issue-N-1, fix/issue-N-2, etc. (exact prefix match)
ATTEMPT=$(git ls-remote --heads "$FORGE_REMOTE" "refs/heads/fix/issue-${ISSUE}" 2>/dev/null | grep -c "refs/heads/fix/issue-${ISSUE}$" || echo 0)
ATTEMPT=$((ATTEMPT + $(git ls-remote --heads "$FORGE_REMOTE" "refs/heads/fix/issue-${ISSUE}-*" 2>/dev/null | wc -l)))
if [ "$ATTEMPT" -gt 0 ]; then
BRANCH="fix/issue-${ISSUE}-${ATTEMPT}"
fi
fi
log "using branch: ${BRANCH}"
if [ "$RECOVERY_MODE" = true ]; then if [ "$RECOVERY_MODE" = true ]; then
if ! worktree_recover "$WORKTREE" "$BRANCH" "$FORGE_REMOTE"; then if ! worktree_recover "$WORKTREE" "$BRANCH" "$FORGE_REMOTE"; then
log "ERROR: worktree recovery failed" log "ERROR: worktree recovery failed"
@ -320,10 +302,6 @@ OPEN_ISSUES_SUMMARY=$(forge_api GET "/issues?state=open&labels=backlog&limit=20&
PUSH_INSTRUCTIONS=$(build_phase_protocol_prompt "$BRANCH" "$FORGE_REMOTE") PUSH_INSTRUCTIONS=$(build_phase_protocol_prompt "$BRANCH" "$FORGE_REMOTE")
# Load lessons from .profile repo if available (pre-session)
profile_load_lessons || true
LESSONS_INJECTION="${LESSONS_CONTEXT:-}"
if [ "$RECOVERY_MODE" = true ]; then if [ "$RECOVERY_MODE" = true ]; then
GIT_DIFF_STAT=$(git -C "$WORKTREE" diff "${FORGE_REMOTE}/${PRIMARY_BRANCH}..HEAD" --stat 2>/dev/null \ GIT_DIFF_STAT=$(git -C "$WORKTREE" diff "${FORGE_REMOTE}/${PRIMARY_BRANCH}..HEAD" --stat 2>/dev/null \
| head -20 || echo "(no diff)") | head -20 || echo "(no diff)")
@ -354,10 +332,6 @@ ${GIT_DIFF_STAT}
3. Address any pending review comments or CI failures. 3. Address any pending review comments or CI failures.
4. Commit and push to \`${BRANCH}\`. 4. Commit and push to \`${BRANCH}\`.
${LESSONS_INJECTION:+## Lessons learned
${LESSONS_INJECTION}
}
${PUSH_INSTRUCTIONS}" ${PUSH_INSTRUCTIONS}"
else else
INITIAL_PROMPT="You are working in a git worktree at ${WORKTREE} on branch ${BRANCH}. INITIAL_PROMPT="You are working in a git worktree at ${WORKTREE} on branch ${BRANCH}.
@ -373,10 +347,6 @@ ${OPEN_ISSUES_SUMMARY}
$(if [ -n "$PRIOR_ART_DIFF" ]; then $(if [ -n "$PRIOR_ART_DIFF" ]; then
printf '## Prior Art (closed PR — DO NOT start from scratch)\n\nA previous PR attempted this issue but was closed without merging. Reuse as much as possible.\n\n```diff\n%s\n```\n' "$PRIOR_ART_DIFF" printf '## Prior Art (closed PR — DO NOT start from scratch)\n\nA previous PR attempted this issue but was closed without merging. Reuse as much as possible.\n\n```diff\n%s\n```\n' "$PRIOR_ART_DIFF"
fi) fi)
${LESSONS_INJECTION:+## Lessons learned
${LESSONS_INJECTION}
}
## Instructions ## Instructions
1. Read AGENTS.md in this repo for project context and coding conventions. 1. Read AGENTS.md in this repo for project context and coding conventions.
@ -480,40 +450,6 @@ Closing as already implemented."
fi fi
log "ERROR: no branch pushed after agent_run" log "ERROR: no branch pushed after agent_run"
# Dump diagnostics
diag_file="${DISINTO_LOG_DIR:-/tmp}/dev/agent-run-last.json"
if [ -f "$diag_file" ]; then
result_text=""; cost_usd=""; num_turns=""
result_text=$(jq -r '.result // "no result field"' "$diag_file" 2>/dev/null | head -50) || result_text="(parse error)"
cost_usd=$(jq -r '.cost_usd // "?"' "$diag_file" 2>/dev/null) || cost_usd="?"
num_turns=$(jq -r '.num_turns // "?"' "$diag_file" 2>/dev/null) || num_turns="?"
log "no_push diagnostics: turns=${num_turns} cost=${cost_usd}"
log "no_push result: ${result_text}"
# Save full output for later analysis
cp "$diag_file" "${DISINTO_LOG_DIR:-/tmp}/dev/no-push-${ISSUE}-$(date +%s).json" 2>/dev/null || true
fi
# Save full session log for debugging
# Session logs are stored in CLAUDE_CONFIG_DIR/projects/{worktree-hash}/{session-id}.jsonl
_wt_hash=$(printf '%s' "$WORKTREE" | md5sum | cut -c1-12)
_cl_config="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
_session_log="${_cl_config}/projects/${_wt_hash}/${_AGENT_SESSION_ID}.jsonl"
if [ -f "$_session_log" ]; then
cp "$_session_log" "${DISINTO_LOG_DIR}/dev/no-push-session-${ISSUE}-$(date +%s).jsonl" 2>/dev/null || true
log "no_push session log saved to ${DISINTO_LOG_DIR}/dev/no-push-session-${ISSUE}-*.jsonl"
fi
# Log session summary for debugging
if [ -f "$_session_log" ]; then
_read_calls=$(grep -c '"type":"read"' "$_session_log" 2>/dev/null || echo "0")
_edit_calls=$(grep -c '"type":"edit"' "$_session_log" 2>/dev/null || echo "0")
_bash_calls=$(grep -c '"type":"bash"' "$_session_log" 2>/dev/null || echo "0")
_text_calls=$(grep -c '"type":"text"' "$_session_log" 2>/dev/null || echo "0")
_failed_calls=$(grep -c '"exit_code":null' "$_session_log" 2>/dev/null || echo "0")
_total_turns=$(grep -c '"type":"turn"' "$_session_log" 2>/dev/null || echo "0")
log "no_push session summary: turns=${_total_turns} reads=${_read_calls} edits=${_edit_calls} bash=${_bash_calls} text=${_text_calls} failed=${_failed_calls}"
fi
issue_block "$ISSUE" "no_push" "Claude did not push branch ${BRANCH}" issue_block "$ISSUE" "no_push" "Claude did not push branch ${BRANCH}"
CLAIMED=false CLAIMED=false
worktree_cleanup "$WORKTREE" worktree_cleanup "$WORKTREE"
@ -561,12 +497,6 @@ if [ "$rc" -eq 0 ]; then
log "PR #${PR_NUMBER} merged" log "PR #${PR_NUMBER} merged"
issue_close "$ISSUE" issue_close "$ISSUE"
# Capture files changed for journal entry (after agent work)
FILES_CHANGED=$(git -C "$WORKTREE" diff "${FORGE_REMOTE}/${PRIMARY_BRANCH}..HEAD" --name-only 2>/dev/null | tr '\n' ',' | sed 's/,$//') || FILES_CHANGED=""
# Write journal entry post-session (before cleanup)
profile_write_journal "$ISSUE" "$ISSUE_TITLE" "merged" "$FILES_CHANGED" || true
# Pull primary branch and push to mirrors # Pull primary branch and push to mirrors
git -C "$REPO_ROOT" fetch "$FORGE_REMOTE" "$PRIMARY_BRANCH" 2>/dev/null || true git -C "$REPO_ROOT" fetch "$FORGE_REMOTE" "$PRIMARY_BRANCH" 2>/dev/null || true
git -C "$REPO_ROOT" checkout "$PRIMARY_BRANCH" 2>/dev/null || true git -C "$REPO_ROOT" checkout "$PRIMARY_BRANCH" 2>/dev/null || true
@ -580,18 +510,6 @@ else
# Exhausted or unrecoverable failure # Exhausted or unrecoverable failure
log "PR walk failed: ${_PR_WALK_EXIT_REASON:-unknown}" log "PR walk failed: ${_PR_WALK_EXIT_REASON:-unknown}"
issue_block "$ISSUE" "${_PR_WALK_EXIT_REASON:-agent_failed}" issue_block "$ISSUE" "${_PR_WALK_EXIT_REASON:-agent_failed}"
# Capture files changed for journal entry (after agent work)
FILES_CHANGED=$(git -C "$WORKTREE" diff "${FORGE_REMOTE}/${PRIMARY_BRANCH}..HEAD" --name-only 2>/dev/null | tr '\n' ',' | sed 's/,$//') || FILES_CHANGED=""
# Write journal entry post-session (before cleanup)
outcome="blocked_${_PR_WALK_EXIT_REASON:-agent_failed}"
profile_write_journal "$ISSUE" "$ISSUE_TITLE" "$outcome" "$FILES_CHANGED" || true
# Cleanup on failure: preserve remote branch and PR for debugging, clean up local worktree
# Remote state (PR and branch) stays open for inspection of CI logs and review comments
worktree_cleanup "$WORKTREE"
rm -f "$SID_FILE" "$IMPL_SUMMARY_FILE"
CLAIMED=false CLAIMED=false
fi fi

View file

@ -94,76 +94,6 @@ is_blocked() {
| jq -e '.[] | select(.name == "blocked")' >/dev/null 2>&1 | jq -e '.[] | select(.name == "blocked")' >/dev/null 2>&1
} }
# =============================================================================
# STALENESS DETECTION FOR IN-PROGRESS ISSUES
# =============================================================================
# Check if a tmux session for a specific issue is alive
# Args: project_name issue_number
# Returns: 0 if session is alive, 1 if not
session_is_alive() {
local project="$1" issue="$2"
local session="dev-${project}-${issue}"
tmux has-session -t "$session" 2>/dev/null
}
# Check if there's an open PR for a specific issue
# Args: project_name issue_number
# Returns: 0 if open PR exists, 1 if not
open_pr_exists() {
local project="$1" issue="$2"
local branch="fix/issue-${issue}"
local pr_num
pr_num=$(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
[ -n "$pr_num" ]
}
# Relabel a stale in-progress issue to blocked with diagnostic comment
# Args: issue_number reason
# Uses shared helpers from lib/issue-lifecycle.sh
relabel_stale_issue() {
local issue="$1" reason="$2"
log "relabeling stale in-progress issue #${issue} to blocked: ${reason}"
# Remove in-progress label
local ip_id
ip_id=$(_ilc_in_progress_id)
if [ -n "$ip_id" ]; then
curl -sf -X DELETE -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${issue}/labels/${ip_id}" >/dev/null 2>&1 || true
fi
# Add blocked label
local bk_id
bk_id=$(_ilc_blocked_id)
if [ -n "$bk_id" ]; then
curl -sf -X POST -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${issue}/labels" \
-d "{\"labels\":[${bk_id}]}" >/dev/null 2>&1 || true
fi
# Post diagnostic comment using shared helper
local comment_body
comment_body=$(
printf '### Stale in-progress issue detected\n\n'
printf '| Field | Value |\n|---|---|\n'
printf '| Detection reason | `%s` |\n' "$reason"
printf '| Timestamp | `%s` |\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf '\n**Status:** This issue was labeled `in-progress` but no active tmux session exists.\n'
printf '**Action required:** A maintainer should triage this issue.\n'
)
_ilc_post_comment "$issue" "$comment_body"
_ilc_log "stale issue #${issue} relabeled to blocked: ${reason}"
}
# ============================================================================= # =============================================================================
# HELPER: handle CI-exhaustion check/block (DRY for 3 call sites) # HELPER: handle CI-exhaustion check/block (DRY for 3 call sites)
# Sets CI_FIX_ATTEMPTS for caller use. Returns 0 if exhausted, 1 if not. # Sets CI_FIX_ATTEMPTS for caller use. Returns 0 if exhausted, 1 if not.
@ -225,10 +155,9 @@ try_direct_merge() {
if [ "$issue_num" -gt 0 ]; then if [ "$issue_num" -gt 0 ]; then
issue_close "$issue_num" issue_close "$issue_num"
# Remove in-progress label (don't re-add backlog — issue is closed) # Remove in-progress label (don't re-add backlog — issue is closed)
IP_ID=$(_ilc_in_progress_id)
curl -sf -X DELETE \ curl -sf -X DELETE \
-H "Authorization: token ${FORGE_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${issue_num}/labels/${IP_ID}" >/dev/null 2>&1 || true "${API}/issues/${issue_num}/labels/in-progress" >/dev/null 2>&1 || true
rm -f "/tmp/dev-session-${PROJECT_NAME}-${issue_num}.sid" \ rm -f "/tmp/dev-session-${PROJECT_NAME}-${issue_num}.sid" \
"/tmp/dev-impl-summary-${PROJECT_NAME}-${issue_num}.txt" "/tmp/dev-impl-summary-${PROJECT_NAME}-${issue_num}.txt"
fi fi
@ -378,11 +307,6 @@ memory_guard 2000
# PRIORITY 1: orphaned in-progress issues # PRIORITY 1: orphaned in-progress issues
# ============================================================================= # =============================================================================
log "checking for in-progress issues" log "checking for in-progress issues"
# Get current bot identity for assignee checks
BOT_USER=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API%%/repos*}/user" | jq -r '.login') || BOT_USER=""
ORPHANS_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ ORPHANS_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues?state=open&labels=in-progress&limit=10&type=issues") "${API}/issues?state=open&labels=in-progress&limit=10&type=issues")
@ -390,34 +314,14 @@ ORPHAN_COUNT=$(echo "$ORPHANS_JSON" | jq 'length')
if [ "$ORPHAN_COUNT" -gt 0 ]; then if [ "$ORPHAN_COUNT" -gt 0 ]; then
ISSUE_NUM=$(echo "$ORPHANS_JSON" | jq -r '.[0].number') ISSUE_NUM=$(echo "$ORPHANS_JSON" | jq -r '.[0].number')
# Staleness check: if no tmux session and no open PR, the issue is stale
SESSION_ALIVE=false
OPEN_PR=false
if tmux has-session -t "dev-${PROJECT_NAME}-${ISSUE_NUM}" 2>/dev/null; then
SESSION_ALIVE=true
fi
if curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls?state=open&limit=20" | \
jq -e --arg branch "fix/issue-${ISSUE_NUM}" \
'.[] | select(.head.ref == $branch)' >/dev/null 2>&1; then
OPEN_PR=true
fi
if [ "$SESSION_ALIVE" = false ] && [ "$OPEN_PR" = false ]; then
log "issue #${ISSUE_NUM} is stale (no active tmux session, no open PR) — relabeling to blocked"
relabel_stale_issue "$ISSUE_NUM" "no_active_session_no_open_pr"
exit 0
fi
# Formula guard: formula-labeled issues should not be worked on by dev-agent. # Formula guard: formula-labeled issues should not be worked on by dev-agent.
# Remove in-progress label and skip to prevent infinite respawn cycle (#115). # Remove in-progress label and skip to prevent infinite respawn cycle (#115).
ORPHAN_LABELS=$(echo "$ORPHANS_JSON" | jq -r '.[0].labels[].name' 2>/dev/null) || true ORPHAN_LABELS=$(echo "$ORPHANS_JSON" | jq -r '.[0].labels[].name' 2>/dev/null) || true
SKIP_LABEL=$(echo "$ORPHAN_LABELS" | grep -oE '^(formula|prediction/dismissed|prediction/unreviewed)$' | head -1) || true SKIP_LABEL=$(echo "$ORPHAN_LABELS" | grep -oE '^(formula|action|prediction/dismissed|prediction/unreviewed)$' | head -1) || true
if [ -n "$SKIP_LABEL" ]; then if [ -n "$SKIP_LABEL" ]; then
log "issue #${ISSUE_NUM} has '${SKIP_LABEL}' label — removing in-progress, skipping" log "issue #${ISSUE_NUM} has '${SKIP_LABEL}' label — removing in-progress, skipping"
IP_ID=$(_ilc_in_progress_id)
curl -sf -X DELETE -H "Authorization: token ${FORGE_TOKEN}" \ curl -sf -X DELETE -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${ISSUE_NUM}/labels/${IP_ID}" >/dev/null 2>&1 || true "${API}/issues/${ISSUE_NUM}/labels/in-progress" >/dev/null 2>&1 || true
exit 0 exit 0
fi fi
@ -428,26 +332,6 @@ if [ "$ORPHAN_COUNT" -gt 0 ]; then
'.[] | select(.head.ref == $branch) | .number' | head -1) || true '.[] | select(.head.ref == $branch) | .number' | head -1) || true
if [ -n "$HAS_PR" ]; then if [ -n "$HAS_PR" ]; then
# Check if branch is stale (behind primary branch)
BRANCH="fix/issue-${ISSUE_NUM}"
AHEAD=$(git rev-list --count "origin/${BRANCH}..origin/${PRIMARY_BRANCH}" 2>/dev/null || echo "999")
if [ "$AHEAD" -gt 0 ]; then
log "issue #${ISSUE_NUM} PR #${HAS_PR} is $AHEAD commits behind ${PRIMARY_BRANCH} — abandoning stale PR"
# Close the PR via API
curl -sf -X PATCH \
-H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/pulls/${HAS_PR}" \
-d '{"state":"closed"}' >/dev/null 2>&1 || true
# Delete the branch via git push
git -C "${PROJECT_REPO_ROOT:-}" push origin --delete "${BRANCH}" 2>/dev/null || true
# Reset to fresh start on primary branch
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
# Exit to restart poll cycle (issue will be picked up fresh)
exit 0
fi
PR_SHA=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ PR_SHA=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls/${HAS_PR}" | jq -r '.head.sha') || true "${API}/pulls/${HAS_PR}" | jq -r '.head.sha') || true
CI_STATE=$(ci_commit_status "$PR_SHA") || true CI_STATE=$(ci_commit_status "$PR_SHA") || true
@ -501,24 +385,9 @@ if [ "$ORPHAN_COUNT" -gt 0 ]; then
else else
log "issue #${ISSUE_NUM} has open PR #${HAS_PR} (CI: ${CI_STATE}, waiting)" log "issue #${ISSUE_NUM} has open PR #${HAS_PR} (CI: ${CI_STATE}, waiting)"
exit 0
fi fi
else else
# Check assignee before adopting orphaned issue log "recovering orphaned issue #${ISSUE_NUM} (no PR found)"
ISSUE_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${ISSUE_NUM}") || true
ASSIGNEE=$(echo "$ISSUE_JSON" | jq -r '.assignee.login // ""') || true
if [ -n "$ASSIGNEE" ] && [ "$ASSIGNEE" != "$BOT_USER" ]; then
log "issue #${ISSUE_NUM} assigned to ${ASSIGNEE} — skipping (not orphaned)"
# Remove in-progress label since this agent isn't working on it
IP_ID=$(_ilc_in_progress_id)
curl -sf -X DELETE -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${ISSUE_NUM}/labels/${IP_ID}" >/dev/null 2>&1 || true
exit 0
fi
log "recovering orphaned issue #${ISSUE_NUM} (no PR found, assigned to ${BOT_USER:-unassigned})"
nohup "${SCRIPT_DIR}/dev-agent.sh" "$ISSUE_NUM" >> "$LOGFILE" 2>&1 & nohup "${SCRIPT_DIR}/dev-agent.sh" "$ISSUE_NUM" >> "$LOGFILE" 2>&1 &
log "started dev-agent PID $! for issue #${ISSUE_NUM} (recovery)" log "started dev-agent PID $! for issue #${ISSUE_NUM} (recovery)"
exit 0 exit 0
@ -652,18 +521,9 @@ for i in $(seq 0 $((BACKLOG_COUNT - 1))); do
ISSUE_NUM=$(echo "$BACKLOG_JSON" | jq -r ".[$i].number") ISSUE_NUM=$(echo "$BACKLOG_JSON" | jq -r ".[$i].number")
ISSUE_BODY=$(echo "$BACKLOG_JSON" | jq -r ".[$i].body // \"\"") ISSUE_BODY=$(echo "$BACKLOG_JSON" | jq -r ".[$i].body // \"\"")
# Check assignee before claiming — skip if assigned to another bot
ISSUE_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${ISSUE_NUM}") || true
ASSIGNEE=$(echo "$ISSUE_JSON" | jq -r '.assignee.login // ""') || true
if [ -n "$ASSIGNEE" ] && [ "$ASSIGNEE" != "$BOT_USER" ]; then
log " #${ISSUE_NUM} assigned to ${ASSIGNEE} — skipping"
continue
fi
# Formula guard: formula-labeled issues must not be picked up by dev-agent. # Formula guard: formula-labeled issues must not be picked up by dev-agent.
ISSUE_LABELS=$(echo "$BACKLOG_JSON" | jq -r ".[$i].labels[].name" 2>/dev/null) || true ISSUE_LABELS=$(echo "$BACKLOG_JSON" | jq -r ".[$i].labels[].name" 2>/dev/null) || true
SKIP_LABEL=$(echo "$ISSUE_LABELS" | grep -oE '^(formula|prediction/dismissed|prediction/unreviewed)$' | head -1) || true SKIP_LABEL=$(echo "$ISSUE_LABELS" | grep -oE '^(formula|action|prediction/dismissed|prediction/unreviewed)$' | head -1) || true
if [ -n "$SKIP_LABEL" ]; then if [ -n "$SKIP_LABEL" ]; then
log "issue #${ISSUE_NUM} has '${SKIP_LABEL}' label — skipping in backlog scan" log "issue #${ISSUE_NUM} has '${SKIP_LABEL}' label — skipping in backlog scan"
continue continue
@ -680,26 +540,6 @@ for i in $(seq 0 $((BACKLOG_COUNT - 1))); do
'.[] | select((.head.ref == $branch) or (.title | contains($num))) | .number' | head -1) || true '.[] | select((.head.ref == $branch) or (.title | contains($num))) | .number' | head -1) || true
if [ -n "$EXISTING_PR" ]; then if [ -n "$EXISTING_PR" ]; then
# Check if branch is stale (behind primary branch)
BRANCH="fix/issue-${ISSUE_NUM}"
AHEAD=$(git rev-list --count "origin/${BRANCH}..origin/${PRIMARY_BRANCH}" 2>/dev/null || echo "999")
if [ "$AHEAD" -gt 0 ]; then
log "issue #${ISSUE_NUM} PR #${EXISTING_PR} is $AHEAD commits behind ${PRIMARY_BRANCH} — abandoning stale PR"
# Close the PR via API
curl -sf -X PATCH \
-H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/pulls/${EXISTING_PR}" \
-d '{"state":"closed"}' >/dev/null 2>&1 || true
# Delete the branch via git push
git -C "${PROJECT_REPO_ROOT:-}" push origin --delete "${BRANCH}" 2>/dev/null || true
# Reset to fresh start on primary branch
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
# Continue to find another ready issue
continue
fi
PR_SHA=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ PR_SHA=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls/${EXISTING_PR}" | jq -r '.head.sha') || true "${API}/pulls/${EXISTING_PR}" | jq -r '.head.sha') || true
CI_STATE=$(ci_commit_status "$PR_SHA") || true CI_STATE=$(ci_commit_status "$PR_SHA") || true

View file

@ -1,28 +1,209 @@
--- ---
name: disinto-factory name: disinto-factory
description: Set up and operate a disinto autonomous code factory. description: Set up and operate a disinto autonomous code factory. Use when bootstrapping a new factory instance, checking on agents and CI, managing the backlog, or troubleshooting the stack.
--- ---
# Disinto Factory # Disinto Factory
You are helping the user set up and operate a **disinto autonomous code factory**. You are helping the user set up and operate a **disinto autonomous code factory** — a system
of bash scripts and Claude CLI that automates the full development lifecycle: picking up
issues, implementing via Claude, creating PRs, running CI, reviewing, merging, and mirroring.
## Guides ## First-time setup
- **[Setup guide](setup.md)** — First-time factory setup: environment, init, verification, backlog seeding Walk the user through these steps interactively. Ask questions where marked with [ASK].
- **[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 ### 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 and init
```bash
git clone https://codeberg.org/johba/disinto.git && cd disinto
```
[ASK] What repo should the factory develop? Options:
- **Itself** (self-development): `bin/disinto init https://codeberg.org/johba/disinto --yes --repo-root $(pwd)`
- **Another project**: `bin/disinto init <repo-url> --yes`
Run the init and watch for:
- All bot users created (dev-bot, review-bot, etc.)
- `WOODPECKER_TOKEN` generated and saved
- Stack containers all started
### 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. 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 add 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
```
### 5. 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
### 6. Watch it work
The dev-agent polls every 5 minutes. Trigger manually to see it immediately:
```bash
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"
```
## 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
```
## Important context ## Important context
- Read `AGENTS.md` for per-agent architecture and file-level docs - Read `AGENTS.md` for per-agent architecture and file-level docs
- Read `VISION.md` for project philosophy - Read `VISION.md` for project philosophy
- The factory uses a single internal Forgejo as its forge, regardless of where mirrors go - 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 - Dev-agent uses `claude -p --resume` for session continuity across CI/review cycles
- Mirror pushes happen automatically after every merge - Mirror pushes happen automatically after every merge (fire-and-forget)
- Cron schedule: dev-poll every 5min, review-poll every 5min, gardener 4x/day - 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)

View file

@ -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.

View file

@ -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
```

View file

@ -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"
```

View file

@ -1,54 +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
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"

View file

@ -1,18 +1,14 @@
FROM debian:bookworm-slim FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \ 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 \ bash curl git jq tmux cron python3 openssh-client ca-certificates \
&& pip3 install --break-system-packages networkx \
&& rm -rf /var/lib/apt/lists/* && 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 # tea CLI — official Gitea/Forgejo CLI for issue/label/comment operations
COPY docker/agents/bin/tea /usr/local/bin/tea # Checksum from https://dl.gitea.com/tea/0.9.2/tea-0.9.2-linux-amd64.sha256
RUN chmod +x /usr/local/bin/tea 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. # Claude CLI is mounted from the host via docker-compose volume.
# No internet access to cli.anthropic.com required at build time. # 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 # Non-root user
RUN useradd -m -u 1000 -s /bin/bash agent RUN useradd -m -u 1000 -s /bin/bash agent
# Copy disinto code into the image COPY entrypoint.sh /entrypoint.sh
COPY . /home/agent/disinto
COPY docker/agents/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
# Entrypoint runs as root to start the cron daemon; # Entrypoint runs as root to start the cron daemon;
# cron jobs execute as the agent user (crontab -u agent). # cron jobs execute as the agent user (crontab -u agent).
WORKDIR /home/agent/disinto WORKDIR /home/agent
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]

View file

@ -1,105 +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
su -s /bin/bash agent -c "git clone http://dev-bot:${FORGE_TOKEN}@forgejo:3000/${FORGE_REPO:-disinto-admin/disinto}.git ${PROJECT_REPO_ROOT}"
log "Repo cloned"
fi
# Install crontab entries for agent user from project TOMLs
install_project_crons() {
local cron_lines="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"
local agents_to_run="review,dev,gardener"
if [ -n "${DISINTO_AGENTS:-}" ]; then
agents_to_run="$DISINTO_AGENTS"
fi
for toml in "${DISINTO_DIR}"/projects/*.toml; do
[ -f "$toml" ] || continue
local pname
pname=$(python3 -c "
import sys, tomllib
with open(sys.argv[1], 'rb') as f:
print(tomllib.load(f)['name'])
" "$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
done
if [ -n "$cron_lines" ]; then
printf '%s\n' "$cron_lines" | crontab -u agent -
log "Installed crontab for agent user (agents: ${agents_to_run})"
else
log "No project TOMLs found — crontab empty"
fi
}
log "Entering poll loop (interval: ${POLL_INTERVAL:-300}s)"
# Install and start cron daemon
DISINTO_DIR="/home/agent/disinto"
install_project_crons
log "Starting cron daemon"
cron
log "cron daemon started"
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}'
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

View file

@ -19,18 +19,7 @@ log() {
# Build crontab from project TOMLs and install for the agent user. # Build crontab from project TOMLs and install for the agent user.
install_project_crons() { install_project_crons() {
local cron_lines="DISINTO_CONTAINER=1 local cron_lines="DISINTO_CONTAINER=1
USER=agent 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
for toml in "${DISINTO_DIR}"/projects/*.toml; do for toml in "${DISINTO_DIR}"/projects/*.toml; do
[ -f "$toml" ] || continue [ -f "$toml" ] || continue
local pname local pname
@ -41,31 +30,15 @@ with open(sys.argv[1], 'rb') as f:
" "$toml" 2>/dev/null) || continue " "$toml" 2>/dev/null) || continue
cron_lines="${cron_lines} cron_lines="${cron_lines}
PROJECT_REPO_ROOT=/home/agent/repos/${pname} # disinto: ${pname}
# disinto: ${pname}" 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
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
# 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" 0 0,6,12,18 * * * cd ${DISINTO_DIR} && bash gardener/gardener-run.sh ${toml} >>/home/agent/data/logs/cron.log 2>&1"
fi
done done
if [ -n "$cron_lines" ]; then if [ -n "$cron_lines" ]; then
printf '%s\n' "$cron_lines" | crontab -u agent - 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 else
log "No project TOMLs found — crontab empty" log "No project TOMLs found — crontab empty"
fi fi
@ -73,9 +46,6 @@ PROJECT_REPO_ROOT=/home/agent/repos/${pname}
log "Agent container starting" 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). # Verify Claude CLI is available (expected via volume mount from host).
if ! command -v claude &>/dev/null; then if ! command -v claude &>/dev/null; then
log "FATAL: claude CLI not found in PATH." log "FATAL: claude CLI not found in PATH."

View file

@ -1,4 +0,0 @@
FROM caddy:alpine
RUN apk add --no-cache bash jq curl git docker-cli
COPY entrypoint-edge.sh /usr/local/bin/entrypoint-edge.sh
ENTRYPOINT ["bash", "/usr/local/bin/entrypoint-edge.sh"]

View file

@ -1,510 +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}"
# Log function
log() {
printf '[%s] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*"
}
# -----------------------------------------------------------------------------
# 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
}
# -----------------------------------------------------------------------------
# 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
# Wait before next poll
sleep 60
done
}
# Run main
main "$@"

View file

@ -1,19 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Set USER before sourcing env.sh (Alpine doesn't set USER)
export USER="${USER:-root}"
DISINTO_VERSION="${DISINTO_VERSION:-main}"
DISINTO_REPO="${FORGE_URL:-http://forgejo:3000}/${FORGE_REPO:-disinto-admin/disinto}.git"
# Shallow clone at the pinned version
if [ ! -d /opt/disinto/.git ]; then
git clone --depth 1 --branch "$DISINTO_VERSION" "$DISINTO_REPO" /opt/disinto
fi
# Start dispatcher in background
bash /opt/disinto/docker/edge/dispatcher.sh &
# Caddy as main process
exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile

View file

@ -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 | | 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) | | 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) | | planner | 382 | Medium — AGENTS.md update, gap analysis | Tmux+formula (done, #232) |
| action-agent | 192 | Light — formula execution | Close to target |

View file

@ -117,7 +117,7 @@ signal to the phase file.
- **Post-loop exit handler (`case $_MONITOR_LOOP_EXIT`):** Must include an - **Post-loop exit handler (`case $_MONITOR_LOOP_EXIT`):** Must include an
`idle_prompt)` branch. Typical actions: log the event, clean up temp files, `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 and (for agents that use escalation) write an escalation entry or notify via
vault/forge. See `dev/dev-agent.sh` and vault/forge. See `dev/dev-agent.sh`, `action/action-agent.sh`, and
`gardener/gardener-agent.sh` for reference implementations. `gardener/gardener-agent.sh` for reference implementations.
## Crash Recovery ## Crash Recovery

View file

@ -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

View file

@ -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"]

View file

@ -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 "ACTION: grooming complete — 0 tech-debt remaining" >> "$RESULT_FILE"
echo 'PHASE:done' > "$PHASE_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): On unrecoverable error (API unavailable, repeated failures):
printf 'PHASE:failed\nReason: %s\n' 'describe what failed' > "$PHASE_FILE" printf 'PHASE:failed\nReason: %s\n' 'describe what failed' > "$PHASE_FILE"

View 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
"""

View file

@ -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 Agents must NEVER execute external actions directly. Any action that touches
an external system (publish, deploy, post, push to external registry, API an external system (publish, deploy, post, push to external registry, API
calls to third-party services) MUST go through vault dispatch i.e., the 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. container executes it with injected secrets.
Scan the diff for these patterns: 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/`, If ANY of these patterns appear in agent code (scripts in `dev/`, `action/`,
`planner/`, `gardener/`, `supervisor/`, `predictor/`, `review/`, `formulas/`, `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 Explain that external actions must use vault dispatch per AD-006. The agent
should file a vault item instead of executing directly. 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): **Exceptions** (do NOT flag these):
- Code inside `vault/` the vault system itself is allowed to handle secrets - Code inside `vault/` the vault system itself is allowed to handle secrets
- References in comments or documentation explaining the architecture - 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`) - Local operations (git push to forge, forge API calls with `FORGE_TOKEN`)
## 6. Re-review (if previous review is provided) ## 6. Re-review (if previous review is provided)

View file

@ -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
```
"""

View file

@ -1,15 +1,16 @@
# formulas/run-gardener.toml — Gardener housekeeping formula # formulas/run-gardener.toml — Gardener housekeeping formula
# #
# Defines the gardener's complete run: grooming (Claude session via # 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 # No memory, no journal. The gardener does mechanical housekeeping
# past runs and improves over time. # 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" name = "run-gardener"
description = "Mechanical housekeeping: grooming, dust bundling, docs update" description = "Mechanical housekeeping: grooming, blocked review, docs update"
version = 1 version = 1
[context] [context]
@ -119,17 +120,15 @@ DUST (trivial — single-line edit, rename, comment, style, whitespace):
of 3+ into one backlog issue. of 3+ into one backlog issue.
VAULT (needs human decision or external resource): VAULT (needs human decision or external resource):
File a vault procurement item using vault_request(): File a vault procurement item at $OPS_REPO_ROOT/vault/pending/<id>.md:
source "$(dirname "$0")/../lib/vault.sh" # <What decision or resource is needed>
TOML_CONTENT="# Vault action: <action_id> ## What
context = \"<description of what decision/resource is needed>\" <description>
unblocks = [\"#NNN\"] ## Why
<which issue this unblocks>
[execution] ## Unblocks
# Commands to run after approval - #NNN — <title>
" Log: echo "VAULT: filed $OPS_REPO_ROOT/vault/pending/<id>.md for #NNN — <reason>" >> "$RESULT_FILE"
PR_NUM=$(vault_request "<action_id>" "$TOML_CONTENT")
echo "VAULT: filed PR #${PR_NUM} for #NNN — <reason>" >> "$RESULT_FILE"
CLEAN (only if truly nothing to do): CLEAN (only if truly nothing to do):
echo 'CLEAN' >> "$RESULT_FILE" echo 'CLEAN' >> "$RESULT_FILE"
@ -143,7 +142,25 @@ Sibling dependency rule (CRITICAL):
NEVER add bidirectional ## Dependencies between siblings (creates deadlocks). NEVER add bidirectional ## Dependencies between siblings (creates deadlocks).
Use ## Related for cross-references: "## Related\n- #NNN (sibling)" 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 For each open issue labeled 'backlog', verify it has the required
sections for dev-agent pickup: sections for dev-agent pickup:
a. Acceptance criteria body must contain at least one checkbox a. Acceptance criteria body must contain at least one checkbox
@ -164,11 +181,28 @@ Sibling dependency rule (CRITICAL):
Well-structured issues (both sections present) are left untouched Well-structured issues (both sections present) are left untouched
they are ready for dev-agent pickup. 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: Processing order:
1. Handle PRIORITY_blockers_starving_factory first promote or resolve 1. Handle PRIORITY_blockers_starving_factory first promote or resolve
2. Quality gate strip backlog from issues missing acceptance criteria or affected files 2. AD alignment check close backlog issues that violate architecture decisions
3. Process tech-debt issues by score (impact/effort) 3. Quality gate strip backlog from issues missing acceptance criteria or affected files
4. Classify remaining items as dust or route to vault 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, Do NOT bundle dust yourself the dust-bundling step handles accumulation,
dedup, TTL expiry, and bundling into backlog issues. dedup, TTL expiry, and bundling into backlog issues.
@ -223,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. 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"] 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]] [[steps]]
@ -349,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. 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. 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]] [[steps]]
@ -406,14 +554,16 @@ executes them after the PR merges.
PR_NUMBER=$(echo "$PR_RESPONSE" | jq -r '.number') PR_NUMBER=$(echo "$PR_RESPONSE" | jq -r '.number')
h. Save PR number for orchestrator tracking: h. Save PR number for orchestrator tracking:
echo "$PR_NUMBER" > /tmp/gardener-pr-${PROJECT_NAME}.txt echo "$PR_NUMBER" > /tmp/gardener-pr-${PROJECT_NAME}.txt
i. The orchestrator handles CI/review via pr_walk_to_merge. i. Signal the orchestrator to monitor CI:
The gardener stays alive to inject CI results and review feedback echo "PHASE:awaiting_ci" > "$PHASE_FILE"
as they come in, then executes the pending-actions manifest after merge. 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): 4. If no file changes existed (step 2 found nothing):
# Nothing to commit — the gardener has no work to do this run. echo "PHASE:done" > "$PHASE_FILE"
exit 0
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"] needs = ["agents-update"]

View file

@ -4,7 +4,7 @@
# planner-run.sh creates a tmux session with Claude (opus) and injects # planner-run.sh creates a tmux session with Claude (opus) and injects
# this formula as context, plus the graph report from build-graph.py. # 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: # v4 changes from v3:
# - Graph report (orphans, cycles, thin objectives, bottlenecks) replaces # - Graph report (orphans, cycles, thin objectives, bottlenecks) replaces
@ -13,8 +13,7 @@
# - 3 steps instead of 6. # - 3 steps instead of 6.
# #
# AGENTS.md maintenance is handled by the gardener (#246). # AGENTS.md maintenance is handled by the gardener (#246).
# All git writes (tree, memory) happen in one commit at the end. # All git writes (tree, journal, memory) happen in one commit at the end.
# Journal writing is delegated to generic profile_write_journal() function.
name = "run-planner" name = "run-planner"
description = "Planner v4: graph-driven planning with tea helpers" 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. A constraint is an unresolved prerequisite blocking the most downstream objectives.
Graph bottlenecks (high betweenness centrality) and thin objectives inform ranking. Graph bottlenecks (high betweenness centrality) and thin objectives inform ranking.
HUMAN_BLOCKED handling (needs human decision or external resource): Stuck issue handling:
- File a vault procurement item instead of skipping. First check for duplicates - BOUNCED/LABEL_CHURN: do NOT re-promote. Dispatch groom-backlog formula instead:
across ALL vault directories (pending/, approved/, fired/) if a file with the tea_file_issue "chore: break down #<N> — bounced <count>x" "<body>" "action"
same slug already exists in any of them, do NOT create a new one. - 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). Naming: $OPS_REPO_ROOT/vault/pending/<project>-<slug>.md (e.g. disinto-github-org.md).
Write with this template: 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)". 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. Do NOT skip or mark as "awaiting human decision" the vault owns the human interface.
Template-or-vision filing gate (for non-stuck constraints): Filing gate (for non-stuck constraints):
1. Read issue templates from .codeberg/ISSUE_TEMPLATE/*.yaml: 1. Check if issue already exists (match by #number in tree or title search)
- bug.yaml: for broken/incorrect behavior (error in logs, failing test) 2. If no issue, create one with tea_file_issue using the template above
- feature.yaml: for new capabilities (prerequisite doesn't exist) 3. If issue exists and is open, skip no duplicates
- 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
Priority label sync: Priority label sync:
- Add priority to current top-5 constraint issues (if missing): - 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"] needs = ["preflight"]
[[steps]] [[steps]]
id = "commit-ops-changes" id = "journal-and-commit"
title = "Write tree, memory, and journal; commit and push" title = "Write tree, journal, optional memory; commit and PR"
description = """ description = """
### 1. Write prerequisite tree ### 1. Write prerequisite tree
Write to: $OPS_REPO_ROOT/prerequisites.md 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. Count "# Planner run —" headers across all journal files.
Check "<!-- summarized-through-run: N -->" in planner-memory.md. Check "<!-- summarized-through-run: N -->" in planner-memory.md.
If (count - N) >= 5 or planner-memory.md missing, write to: 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. Include: run counter marker, date, constraint focus, patterns, direction.
Keep under 100 lines. Replace entire file. Keep under 100 lines. Replace entire file.
### 3. Commit ops repo changes ### 4. Commit ops repo changes
Commit the ops repo changes (prerequisites, memory, vault items): Commit the ops repo changes (prerequisites, journal, memory, vault items):
cd "$OPS_REPO_ROOT" 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 git add -u
if ! git diff --cached --quiet; then if ! git diff --cached --quiet; then
git commit -m "chore: planner run $(date -u +%Y-%m-%d)" git commit -m "chore: planner run $(date -u +%Y-%m-%d)"
git push origin "$PRIMARY_BRANCH" git push origin "$PRIMARY_BRANCH"
fi fi
cd "$PROJECT_REPO_ROOT" 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"] needs = ["triage-and-plan"]

View file

@ -3,7 +3,7 @@
# Trigger: action issue created by planner (gap analysis), dev-poll (post-merge # Trigger: action issue created by planner (gap analysis), dev-poll (post-merge
# hook detecting site/ changes), or gardener (periodic SHA drift check). # 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. # as a comment, and closes the issue.
name = "run-publish-site" name = "run-publish-site"

View file

@ -5,7 +5,7 @@
# the action and notifies the human for one-click copy-paste execution. # the action and notifies the human for one-click copy-paste execution.
# #
# Trigger: action issue created by planner or any formula. # 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, # to vault/outreach/{platform}/drafts/, notifies the human via the forge,
# and closes the issue. # and closes the issue.
# #

View file

@ -159,7 +159,7 @@ human judgment, file a vault procurement item:
<impact on factory health reference the priority level> <impact on factory health reference the priority level>
## Unblocks ## Unblocks
- Factory health: <what this resolves> - 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: Read the relevant best-practices file before taking action:
cat "$OPS_REPO_ROOT/knowledge/memory.md" # P0 cat "$OPS_REPO_ROOT/knowledge/memory.md" # P0
@ -241,16 +241,6 @@ 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. 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. 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, write the phase signal: After writing the journal, write the phase signal:
echo 'PHASE:done' > "$PHASE_FILE" echo 'PHASE:done' > "$PHASE_FILE"
""" """

View file

@ -1,4 +1,4 @@
<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 --> <!-- last-reviewed: f32707ba659de278a3af434e3549fb8a8dce9d3a -->
# Gardener Agent # Gardener Agent
**Role**: Backlog grooming — detect duplicate issues, missing acceptance **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` — injects CI results and review feedback, re-signals
`PHASE:awaiting_ci` after fixes, signals `PHASE:awaiting_review` on CI pass. `PHASE:awaiting_ci` after fixes, signals `PHASE:awaiting_review` on CI pass.
Executes pending-actions manifest after PR merge. Executes pending-actions manifest after PR merge.
- `formulas/run-gardener.toml` — Execution spec: preflight, grooming, dust-bundling, - `formulas/run-gardener.toml` — Execution spec: preflight, grooming, dust-bundling, blocked-review, agents-update, commit-and-pr
agents-update, commit-and-pr
- `gardener/pending-actions.json` — Manifest of deferred repo actions (label changes, - `gardener/pending-actions.json` — Manifest of deferred repo actions (label changes,
closures, comments, issue creation). Written during grooming steps, committed to the closures, comments, issue creation). Written during grooming steps, committed to the
PR, reviewed alongside AGENTS.md changes, executed by gardener-run.sh after merge. 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 → **Lifecycle**: gardener-run.sh (cron 0,6,12,18) → `check_active gardener` → lock + memory guard →
load formula + context → create tmux session → load formula + context → create tmux session →
Claude grooms backlog (writes proposed actions to manifest), bundles dust, 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` `PHASE:awaiting_ci` (stays alive) → CI pass → `PHASE:awaiting_review`
review feedback → address + re-signal → merge → gardener-run.sh executes review feedback → address + re-signal → merge → gardener-run.sh executes
manifest actions via API → `PHASE:done`. When blocked on external resources manifest actions via API → `PHASE:done`. When blocked on external resources

50
gardener/PROMPT.md Normal file
View 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.

View file

@ -45,7 +45,7 @@ source "$FACTORY_ROOT/lib/agent-sdk.sh"
# shellcheck source=../lib/pr-lifecycle.sh # shellcheck source=../lib/pr-lifecycle.sh
source "$FACTORY_ROOT/lib/pr-lifecycle.sh" source "$FACTORY_ROOT/lib/pr-lifecycle.sh"
LOG_FILE="${DISINTO_LOG_DIR}/gardener/gardener.log" LOG_FILE="$SCRIPT_DIR/gardener.log"
# shellcheck disable=SC2034 # consumed by agent-sdk.sh # shellcheck disable=SC2034 # consumed by agent-sdk.sh
LOGFILE="$LOG_FILE" LOGFILE="$LOG_FILE"
# shellcheck disable=SC2034 # consumed by agent-sdk.sh # shellcheck disable=SC2034 # consumed by agent-sdk.sh
@ -64,19 +64,10 @@ check_memory 2000
log "--- Gardener run start ---" log "--- Gardener run start ---"
# ── Resolve agent identity for .profile repo ────────────────────────────
if [ -z "${AGENT_IDENTITY:-}" ] && [ -n "${FORGE_GARDENER_TOKEN:-}" ]; then
AGENT_IDENTITY=$(curl -sf -H "Authorization: token ${FORGE_GARDENER_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 + 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 build_context_block AGENTS.md
# ── Prepare .profile context (lessons injection) ─────────────────────────
formula_prepare_profile_context
# ── Read scratch file (compaction survival) ─────────────────────────────── # ── Read scratch file (compaction survival) ───────────────────────────────
SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE") SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE") SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
@ -114,7 +105,7 @@ 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. Fix what you can. File vault items for what you cannot. Do NOT ask permission — act first, report after.
## Project context ## Project context
${CONTEXT_BLOCK}$(formula_lessons_block) ${CONTEXT_BLOCK}
${SCRATCH_CONTEXT:+${SCRATCH_CONTEXT} ${SCRATCH_CONTEXT:+${SCRATCH_CONTEXT}
} }
## Result file ## Result file
@ -343,8 +334,5 @@ else
rm -f "$SCRATCH_FILE" rm -f "$SCRATCH_FILE"
fi 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" rm -f "$GARDENER_PR_FILE"
log "--- Gardener run done ---" log "--- Gardener run done ---"

View file

@ -1,17 +1,32 @@
[ [
{ {
"action": "remove_label", "action": "edit_body",
"issue": 240, "issue": 765,
"label": "blocked" "body": "Depends on: none\n\n## Goal\n\nThe disinto website becomes a versioned artifact: built by CI, published to Codeberg's generic package registry, deployed to staging automatically. Version visible in footer.\n\n## Files to add/change\n\n### `site/VERSION`\n```\n0.1.0\n```\n\n### `site/build.sh`\n```bash\n#!/bin/bash\nVERSION=$(cat VERSION)\nmkdir -p dist\ncp *.html *.jpg *.webp *.png *.ico *.xml robots.txt dist/\nsed -i \"s|Built from scrap, powered by a single battery.|v${VERSION} · Built from scrap, powered by a single battery.|\" dist/index.html\necho \"$VERSION\" > dist/VERSION\n```\n\n### `site/index.html`\nNo template placeholder needed — `build.sh` does the sed replacement on the existing footer text.\n\n### `.woodpecker/site.yml`\n```yaml\nwhen:\n path: \"site/**\"\n event: push\n branch: main\n\nsteps:\n - name: build\n image: alpine\n commands:\n - cd site && sh build.sh\n - VERSION=$(cat site/VERSION)\n - tar czf site-${VERSION}.tar.gz -C site/dist .\n\n - name: publish\n image: alpine\n commands:\n - apk add curl\n - VERSION=$(cat site/VERSION)\n - >-\n curl -sf --user \"johba:$$FORGE_TOKEN\"\n --upload-file site-${VERSION}.tar.gz\n \"https://codeberg.org/api/packages/johba/generic/disinto-site/${VERSION}/site-${VERSION}.tar.gz\"\n environment:\n FORGE_TOKEN:\n from_secret: forge_token\n\n - name: deploy-staging\n image: alpine\n commands:\n - apk add curl\n - VERSION=$(cat site/VERSION)\n - >-\n curl -sf --user \"johba:$$FORGE_TOKEN\"\n \"https://codeberg.org/api/packages/johba/generic/disinto-site/${VERSION}/site-${VERSION}.tar.gz\"\n -o site.tar.gz\n - rm -rf /srv/staging/*\n - tar xzf site.tar.gz -C /srv/staging/\n environment:\n FORGE_TOKEN:\n from_secret: forge_token\n volumes:\n - /home/debian/staging-site:/srv/staging\n```\n\n## Infra setup (manual, before first run)\n- `mkdir -p /home/debian/staging-site`\n- Add to Caddyfile: `staging.disinto.ai { root * /home/debian/staging-site; file_server }`\n- DNS: `staging.disinto.ai` A record → same IP as `disinto.ai`\n- Reload Caddy: `sudo systemctl reload caddy`\n- Add `forge_token` as Woodpecker repo secret for johba/disinto (if not already set)\n- Add `/home/debian/staging-site` to `WOODPECKER_BACKEND_DOCKER_VOLUMES`\n\n## Verification\n- [ ] Merge PR that touches `site/` → CI runs site pipeline\n- [ ] Package appears at `codeberg.org/johba/-/packages/generic/disinto-site/0.1.0`\n- [ ] `staging.disinto.ai` serves the site with `v0.1.0` in footer\n- [ ] `disinto.ai` (production) unchanged\n\n## Related\n- #764 — docker stack edge proxy + staging (future: this moves inside the stack)\n- #755 — vault-gated production promotion (production deploy comes later)\n\n## Affected files\n- `site/VERSION` — new, holds current version string\n- `site/build.sh` — new, builds dist/ with version injected into footer\n- `.woodpecker/site.yml` — new, CI pipeline for build/publish/deploy-staging"
},
{
"action": "edit_body",
"issue": 764,
"body": "Depends on: none (builds on existing docker-compose generation in `bin/disinto`)\n\n## Design\n\n`disinto init` + `disinto up` starts two additional containers as base factory infrastructure:\n\n### Edge proxy (Caddy)\n- Reverse proxies to Forgejo and Woodpecker\n- Serves staging site\n- Runs on ports 80/443\n- At bootstrap: IP-only, self-signed TLS or HTTP\n- Domain + Let's Encrypt added later via vault resource request\n\n### Staging container (Caddy)\n- Static file server for the project's staging artifacts\n- Starts with a default \"Nothing shipped yet\" page\n- CI pipelines write to a shared volume to update staging content\n- No vault approval needed — staging is the factory's sandbox\n\n### docker-compose addition\n```yaml\nservices:\n edge:\n image: caddy:alpine\n ports:\n - \"80:80\"\n - \"443:443\"\n volumes:\n - ./Caddyfile:/etc/caddy/Caddyfile\n - caddy_data:/data\n depends_on:\n - forgejo\n - woodpecker-server\n - staging\n\n staging:\n image: caddy:alpine\n volumes:\n - staging-site:/srv/site\n # Not exposed directly — edge proxies to it\n\nvolumes:\n caddy_data:\n staging-site:\n```\n\n### Caddyfile (generated by `disinto init`)\n```\n# IP-only at bootstrap, domain added later\n:80 {\n handle /forgejo/* {\n reverse_proxy forgejo:3000\n }\n handle /ci/* {\n reverse_proxy woodpecker-server:8000\n }\n handle {\n reverse_proxy staging:80\n }\n}\n```\n\n### Staging update flow\n1. CI builds artifact (site tarball, etc.)\n2. CI step writes to `staging-site` volume\n3. Staging container serves updated content immediately\n4. No restart needed — Caddy serves files directly\n\n### Domain lifecycle\n- Bootstrap: no domain, edge serves on IP\n- Later: factory files vault resource request for domain\n- Human buys domain, sets DNS\n- Caddyfile updated with domain, Let's Encrypt auto-provisions TLS\n\n## Affected files\n- `bin/disinto` — `generate_compose()` adds edge + staging services\n- New: default staging page (\"Nothing shipped yet\")\n- New: Caddyfile template in `docker/`\n\n## Related\n- #755 — vault-gated deployment promotion (production comes later)\n- #757 — ops repo (domain is a resource requested through vault)\n\n## Acceptance criteria\n- [ ] `disinto init` generates a `docker-compose.yml` that includes `edge` (Caddy) and `staging` containers\n- [ ] Edge proxy routes `/forgejo/*` → Forgejo, `/ci/*` → Woodpecker, default → staging container\n- [ ] Staging container serves a default \"Nothing shipped yet\" page on first boot\n- [ ] `docker/` directory contains a Caddyfile template generated by `disinto init`\n- [ ] `disinto up` starts all containers including edge and staging without manual steps"
},
{
"action": "edit_body",
"issue": 761,
"body": "Depends on: #747\n\n## Design\n\nEach agent account on the bundled Forgejo gets a `.profile` repo. This repo holds the agent's formula (copied from disinto at creation time) and its journal.\n\n### Structure\n```\n{agent-bot}/.profile/\n├── formula.toml # snapshot of the formula at agent creation time\n├── journal/ # daily logs of what the agent did\n│ ├── 2026-03-26.md\n│ └── ...\n└── knowledge/ # learned patterns, best-practices (optional, agent can evolve)\n```\n\n### Lifecycle\n1. **Create agent** — `disinto init` or `disinto spawn-agent` creates Forgejo account + `.profile` repo\n2. **Copy formula** — current `formulas/{role}.toml` from disinto repo is copied to `.profile/formula.toml`\n3. **Agent reads its own formula** — at session start, agent reads from its `.profile`, not from the disinto repo\n4. **Agent writes journal** — daily entries pushed to `.profile/journal/`\n5. **Agent can evolve knowledge** — best-practices, heuristics, patterns written to `.profile/knowledge/`\n\n### What this enables\n\n**A/B testing formulas:** Create two agents from different formula versions, run both against the same backlog, compare results (cycle time, CI pass rate, review rejection rate).\n\n**Rollback:** New formula worse? Kill agent, spawn from older formula version.\n\n**Audit:** What formula was this agent running when it produced that PR? Check its `.profile` at that git commit.\n\n**Drift tracking:** Diff what an agent learned (`.profile/knowledge/`) vs what it started with. Measure formula evolution over time.\n\n**Portability:** Move agent to different box — `git clone` its `.profile`.\n\n### Disinto repo becomes the template\n\n```\ndisinto repo:\n formulas/dev-agent.toml ← canonical template, evolves\n formulas/review-agent.toml\n formulas/planner.toml\n ...\n\nRunning agents:\n dev-bot-v2/.profile/formula.toml ← snapshot from formulas/dev-agent.toml@v2\n dev-bot-v3/.profile/formula.toml ← snapshot from formulas/dev-agent.toml@v3\n review-bot/.profile/formula.toml ← snapshot from formulas/review-agent.toml\n```\n\nThe formula in the disinto repo is the template. The `.profile` copy is the instance. They can diverge — that's a feature, not a bug.\n\n## Affected files\n- `bin/disinto` — agent creation copies formula to .profile\n- Agent session scripts — read formula from .profile instead of local formulas/ dir\n- Planner/supervisor — can read other agents' journals from their .profile repos\n\n## Related\n- #747 — per-agent Forgejo accounts (prerequisite)\n- #757 — ops repo (shared concerns stay there: vault, portfolio, resources)\n\n## Acceptance criteria\n- [ ] `disinto spawn-agent` (or `disinto init`) creates a Forgejo account + `.profile` repo for each agent bot\n- [ ] Current `formulas/{role}.toml` is copied to `.profile/formula.toml` at agent creation time\n- [ ] Agent session script reads its formula from `.profile/formula.toml`, not from the repo's `formulas/` directory\n- [ ] Agent writes daily journal entries to `.profile/journal/YYYY-MM-DD.md`"
},
{
"action": "edit_body",
"issue": 742,
"body": "## Problem\n\n`gardener/recipes/*.toml` (4 files: cascade-rebase, chicken-egg-ci, flaky-test, shellcheck-violations) are an older pattern predating `formulas/*.toml`. Two systems for the same thing.\n\n## Fix\n\nMigrate any unique content from recipes to the gardener formula or to new formulas. Delete the recipes directory.\n\n## Affected files\n- `gardener/recipes/*.toml` — delete after migration\n- `formulas/run-gardener.toml` — absorb relevant content\n- Gardener scripts that reference recipes/\n\n## Acceptance criteria\n- [ ] Contents of `gardener/recipes/*.toml` are diff'd against `formulas/run-gardener.toml` — any unique content is migrated\n- [ ] `gardener/recipes/` directory is deleted\n- [ ] No scripts in `gardener/` reference the `recipes/` path after migration\n- [ ] ShellCheck passes on all modified scripts"
}, },
{ {
"action": "add_label", "action": "add_label",
"issue": 240, "issue": 742,
"label": "backlog" "label": "backlog"
}, },
{ {
"action": "comment", "action": "add_label",
"issue": 240, "issue": 741,
"body": "Gardener: PR #242 was closed without merging (implementation was empty). Re-queuing this issue for dev-agent pickup. The fix is well-scoped and blocks #239." "label": "backlog"
} }
] ]

View 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)"

View 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"

View 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"

View 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"

View file

@ -1,4 +1,4 @@
<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 --> <!-- last-reviewed: f32707ba659de278a3af434e3549fb8a8dce9d3a -->
# Shared Helpers (`lib/`) # Shared Helpers (`lib/`)
All agents source `lib/env.sh` as their first action. Additional helpers are All agents source `lib/env.sh` as their first action. Additional helpers are
@ -6,22 +6,19 @@ sourced as needed.
| File | What it provides | Sourced by | | 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. | Every agent | | `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 — vault redesign in progress, see #73-#77). `ci_get_logs <pipeline_number> [--step <name>]` — reads CI logs from Woodpecker SQLite database; outputs last 200 lines to stdout. Requires mounted woodpecker-data volume at /woodpecker-data. | dev-poll, review-poll, review-pr, supervisor-poll | | `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-debug.sh` | CLI tool for Woodpecker CI: `list`, `status`, `logs`, `failures` subcommands. Not sourced — run directly. | Humans / dev-agent (tool access) |
| `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), supervisor-poll (per-project iteration) | | `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/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. `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/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. `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, 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, predictor-run.sh, supervisor-run.sh. | cron entry points | | `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/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/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]`. | file-action-issue.sh, phase-handler.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]`. | 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/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/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/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, action-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/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), action-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/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), action-agent.sh (future) |
| `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 | | `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 |
| `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) |

View file

@ -58,35 +58,4 @@ agent_run() {
printf '%s' "$new_sid" > "$SID_FILE" printf '%s' "$new_sid" > "$SID_FILE"
log "agent_run: session_id=${new_sid:0:12}..." log "agent_run: session_id=${new_sid:0:12}..."
fi 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" ]; 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)"
output=$(cd "$run_dir" && 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") || true
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
} }

View file

@ -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

View file

@ -17,11 +17,6 @@ REPO="${FORGE_REPO}"
API="${WOODPECKER_SERVER}/api/repos/${WOODPECKER_REPO_ID}" API="${WOODPECKER_SERVER}/api/repos/${WOODPECKER_REPO_ID}"
api() { 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" curl -sf -H "Authorization: Bearer ${WOODPECKER_TOKEN}" "${API}/$1"
} }

View file

@ -267,42 +267,3 @@ ci_promote() {
echo "$new_num" 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
}

View file

@ -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()

View file

@ -13,7 +13,7 @@ FACTORY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [ "${DISINTO_CONTAINER:-}" = "1" ]; then if [ "${DISINTO_CONTAINER:-}" = "1" ]; then
DISINTO_DATA_DIR="${HOME}/data" DISINTO_DATA_DIR="${HOME}/data"
DISINTO_LOG_DIR="${DISINTO_DATA_DIR}/logs" DISINTO_LOG_DIR="${DISINTO_DATA_DIR}/logs"
mkdir -p "${DISINTO_DATA_DIR}" "${DISINTO_LOG_DIR}"/{dev,action,review,supervisor,vault,site,metrics,gardener} mkdir -p "${DISINTO_DATA_DIR}" "${DISINTO_LOG_DIR}"/{dev,action,review,supervisor,vault,site,metrics}
else else
DISINTO_LOG_DIR="${FACTORY_ROOT}" DISINTO_LOG_DIR="${FACTORY_ROOT}"
fi fi
@ -28,44 +28,18 @@ export DISINTO_LOG_DIR
if [ -f "$FACTORY_ROOT/.env.enc" ] && command -v sops &>/dev/null; then if [ -f "$FACTORY_ROOT/.env.enc" ] && command -v sops &>/dev/null; then
set -a set -a
_saved_forge_url="${FORGE_URL:-}" _saved_forge_url="${FORGE_URL:-}"
_saved_forge_token="${FORGE_TOKEN:-}" eval "$(sops -d --output-type dotenv "$FACTORY_ROOT/.env.enc" 2>/dev/null)" \
# Use temp file + validate dotenv format before sourcing (avoids eval injection) || echo "Warning: failed to decrypt .env.enc — secrets not loaded" >&2
# 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"
# 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
fi
rm -f "$_tmpenv"
set +a set +a
[ -n "$_saved_forge_url" ] && export FORGE_URL="$_saved_forge_url" [ -n "$_saved_forge_url" ] && export FORGE_URL="$_saved_forge_url"
[ -n "$_saved_forge_token" ] && export FORGE_TOKEN="$_saved_forge_token"
elif [ -f "$FACTORY_ROOT/.env" ]; then elif [ -f "$FACTORY_ROOT/.env" ]; then
# Preserve compose-injected FORGE_URL (localhost in .env != forgejo in Docker) # Preserve compose-injected FORGE_URL (localhost in .env != forgejo in Docker)
_saved_forge_url="${FORGE_URL:-}" _saved_forge_url="${FORGE_URL:-}"
_saved_forge_token="${FORGE_TOKEN:-}"
set -a set -a
# shellcheck source=/dev/null # shellcheck source=/dev/null
source "$FACTORY_ROOT/.env" source "$FACTORY_ROOT/.env"
set +a set +a
[ -n "$_saved_forge_url" ] && export FORGE_URL="$_saved_forge_url" [ -n "$_saved_forge_url" ] && export FORGE_URL="$_saved_forge_url"
[ -n "$_saved_forge_token" ] && export FORGE_TOKEN="$_saved_forge_token"
fi fi
# PATH: foundry, node, system # PATH: foundry, node, system
@ -95,10 +69,10 @@ export FORGE_GARDENER_TOKEN="${FORGE_GARDENER_TOKEN:-${FORGE_TOKEN}}"
export FORGE_VAULT_TOKEN="${FORGE_VAULT_TOKEN:-${FORGE_TOKEN}}" export FORGE_VAULT_TOKEN="${FORGE_VAULT_TOKEN:-${FORGE_TOKEN}}"
export FORGE_SUPERVISOR_TOKEN="${FORGE_SUPERVISOR_TOKEN:-${FORGE_TOKEN}}" export FORGE_SUPERVISOR_TOKEN="${FORGE_SUPERVISOR_TOKEN:-${FORGE_TOKEN}}"
export FORGE_PREDICTOR_TOKEN="${FORGE_PREDICTOR_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: FORGE_BOT_USERNAMES > legacy CODEBERG_BOT_USERNAMES # 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,architect-bot}}" 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 export CODEBERG_BOT_USERNAMES="${FORGE_BOT_USERNAMES}" # backwards compat
# Project config (FORGE_* preferred, CODEBERG_* fallback) # Project config (FORGE_* preferred, CODEBERG_* fallback)
@ -134,7 +108,7 @@ export CLAUDE_TIMEOUT="${CLAUDE_TIMEOUT:-7200}"
# Vault-only token guard (#745): external-action tokens (GITHUB_TOKEN, CLAWHUB_TOKEN) # 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 # 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. # even an accidental .env inclusion cannot leak them into agent sessions.
unset GITHUB_TOKEN 2>/dev/null || true unset GITHUB_TOKEN 2>/dev/null || true
unset CLAWHUB_TOKEN 2>/dev/null || true unset CLAWHUB_TOKEN 2>/dev/null || true
@ -148,62 +122,10 @@ log() {
printf '[%s] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" printf '[%s] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*"
} }
# ============================================================================= # Forge API helper — usage: forge_api GET /issues?state=open
# 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() { forge_api() {
local method="$1" path="$2" local method="$1" path="$2"
shift 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" \ curl -sf -X "$method" \
-H "Authorization: token ${FORGE_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
@ -227,8 +149,7 @@ forge_api_all() {
page=1 page=1
while true; do while true; do
page_items=$(forge_api GET "${path_prefix}${sep}limit=50&page=${page}") page_items=$(forge_api GET "${path_prefix}${sep}limit=50&page=${page}")
count=$(printf '%s' "$page_items" | jq 'length' 2>/dev/null) || count=0 count=$(printf '%s' "$page_items" | jq 'length')
[ -z "$count" ] && count=0
[ "$count" -eq 0 ] && break [ "$count" -eq 0 ] && break
all_items=$(printf '%s\n%s' "$all_items" "$page_items" | jq -s 'add') all_items=$(printf '%s\n%s' "$all_items" "$page_items" | jq -s 'add')
[ "$count" -lt 50 ] && break [ "$count" -lt 50 ] && break
@ -236,23 +157,13 @@ forge_api_all() {
done done
printf '%s' "$all_items" printf '%s' "$all_items"
} }
# Backwards-compat alias
codeberg_api_all() { forge_api_all "$@"; }
# ============================================================================= # Woodpecker API helper
# WOODPECKER API HELPER
# =============================================================================
# Usage: woodpecker_api /repos/{id}/pipelines
# Validates WOODPECKER_SERVER before use to prevent URL injection attacks.
# =============================================================================
woodpecker_api() { woodpecker_api() {
local path="$1" local path="$1"
shift 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 \ curl -sfL \
-H "Authorization: Bearer ${WOODPECKER_TOKEN}" \ -H "Authorization: Bearer ${WOODPECKER_TOKEN}" \
"${WOODPECKER_SERVER}/api${path}" "$@" "${WOODPECKER_SERVER}/api${path}" "$@"

View file

@ -13,7 +13,6 @@
# build_prompt_footer [EXTRA_API] — sets PROMPT_FOOTER (API ref + env + phase) # build_prompt_footer [EXTRA_API] — sets PROMPT_FOOTER (API ref + env + phase)
# run_formula_and_monitor AGENT [TIMEOUT] [CALLBACK] — session start, inject, monitor, log # run_formula_and_monitor AGENT [TIMEOUT] [CALLBACK] — session start, inject, monitor, log
# formula_phase_callback PHASE — standard crash-recovery callback # formula_phase_callback PHASE — standard crash-recovery callback
# formula_prepare_profile_context — load lessons from .profile repo (pre-session)
# #
# Requires: lib/agent-session.sh sourced first (for create_agent_session, # Requires: lib/agent-session.sh sourced first (for create_agent_session,
# agent_kill_session, agent_inject_into_session). # agent_kill_session, agent_inject_into_session).
@ -44,11 +43,6 @@ acquire_cron_lock() {
# Exits 0 (skip) if available memory is below MIN_MB (default 2000). # Exits 0 (skip) if available memory is below MIN_MB (default 2000).
check_memory() { check_memory() {
local min_mb="${1:-2000}" local min_mb="${1:-2000}"
# Graceful fallback if free command is not available (procps not installed)
if ! command -v free &>/dev/null; then
log "run: free not found, skipping memory check"
return 0
fi
local avail_mb local avail_mb
avail_mb=$(free -m | awk '/Mem:/{print $7}') avail_mb=$(free -m | awk '/Mem:/{print $7}')
if [ "${avail_mb:-0}" -lt "$min_mb" ]; then if [ "${avail_mb:-0}" -lt "$min_mb" ]; then
@ -57,417 +51,6 @@ check_memory() {
fi fi
} }
# ── 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
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
}
# ── .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="${1:-${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="${1:-${AGENT_IDENTITY:-}}"
local model="${2:-${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 \
--max-tokens 1000 \
${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 \
--max-tokens 500 \
${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 ────────────────────────────────────────────────────── # ── Formula loading ──────────────────────────────────────────────────────
# load_formula FORMULA_FILE # load_formula FORMULA_FILE
@ -482,60 +65,6 @@ load_formula() {
FORMULA_CONTENT=$(cat "$formula_file") 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 ...] # build_context_block FILE [FILE ...]
# Reads each file from $PROJECT_REPO_ROOT and builds CONTEXT_BLOCK. # Reads each file from $PROJECT_REPO_ROOT and builds CONTEXT_BLOCK.
# Files prefixed with "ops:" are read from $OPS_REPO_ROOT instead. # Files prefixed with "ops:" are read from $OPS_REPO_ROOT instead.
@ -754,14 +283,8 @@ build_graph_section() {
--project-root "$PROJECT_REPO_ROOT" \ --project-root "$PROJECT_REPO_ROOT" \
--output "$report" 2>>"$LOG_FILE"; then --output "$report" 2>>"$LOG_FILE"; then
# shellcheck disable=SC2034 # shellcheck disable=SC2034
local report_content GRAPH_SECTION=$(printf '\n## Structural analysis\n```json\n%s\n```\n' \
report_content="$(cat "$report")" "$(cat "$report")")
# shellcheck disable=SC2034
GRAPH_SECTION="
## Structural analysis
\`\`\`json
${report_content}
\`\`\`"
log "graph report generated: $(jq -r '.stats | "\(.nodes) nodes, \(.edges) edges"' "$report")" log "graph report generated: $(jq -r '.stats | "\(.nodes) nodes, \(.edges) edges"' "$report")"
else else
log "WARN: build-graph.py failed — continuing without structural analysis" log "WARN: build-graph.py failed — continuing without structural analysis"

View file

@ -45,16 +45,16 @@ _ilc_log() {
# Label ID caching — lookup once per name, cache in globals. # Label ID caching — lookup once per name, cache in globals.
# Pattern follows ci-helpers.sh (ensure_blocked_label_id). # Pattern follows ci-helpers.sh (ensure_blocked_label_id).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
declare -A _ILC_LABEL_IDS _ILC_BACKLOG_ID=""
_ILC_LABEL_IDS["backlog"]="" _ILC_IN_PROGRESS_ID=""
_ILC_LABEL_IDS["in-progress"]="" _ILC_BLOCKED_ID=""
_ILC_LABEL_IDS["blocked"]=""
# _ilc_ensure_label_id LABEL_NAME [COLOR] # _ilc_ensure_label_id VARNAME LABEL_NAME [COLOR]
# Looks up label by name, creates if missing, caches in associative array. # Generic: looks up label by name, creates if missing, caches in the named var.
_ilc_ensure_label_id() { _ilc_ensure_label_id() {
local name="$1" color="${2:-#e0e0e0}" local varname="$1" name="$2" color="${3:-#e0e0e0}"
local current="${_ILC_LABEL_IDS[$name]:-}" local current
eval "current=\"\${${varname}:-}\""
if [ -n "$current" ]; then if [ -n "$current" ]; then
printf '%s' "$current" printf '%s' "$current"
return 0 return 0
@ -71,45 +71,21 @@ _ilc_ensure_label_id() {
| jq -r '.id // empty' 2>/dev/null || true) | jq -r '.id // empty' 2>/dev/null || true)
fi fi
if [ -n "$label_id" ]; then if [ -n "$label_id" ]; then
_ILC_LABEL_IDS["$name"]="$label_id" eval "${varname}=\"${label_id}\""
fi fi
printf '%s' "$label_id" printf '%s' "$label_id"
} }
_ilc_backlog_id() { _ilc_ensure_label_id "backlog" "#0075ca"; } _ilc_backlog_id() { _ilc_ensure_label_id _ILC_BACKLOG_ID "backlog" "#0075ca"; }
_ilc_in_progress_id() { _ilc_ensure_label_id "in-progress" "#1d76db"; } _ilc_in_progress_id() { _ilc_ensure_label_id _ILC_IN_PROGRESS_ID "in-progress" "#1d76db"; }
_ilc_blocked_id() { _ilc_ensure_label_id "blocked" "#e11d48"; } _ilc_blocked_id() { _ilc_ensure_label_id _ILC_BLOCKED_ID "blocked" "#e11d48"; }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# issue_claim — assign issue to bot, add "in-progress" label, remove "backlog". # issue_claim — add "in-progress" label, remove "backlog" label.
# Args: issue_number # Args: issue_number
# Returns: 0 on success, 1 if already assigned to another agent
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
issue_claim() { issue_claim() {
local issue="$1" 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 local ip_id bl_id
ip_id=$(_ilc_in_progress_id) ip_id=$(_ilc_in_progress_id)
bl_id=$(_ilc_backlog_id) bl_id=$(_ilc_backlog_id)
@ -126,23 +102,14 @@ issue_claim() {
"${FORGE_API}/issues/${issue}/labels/${bl_id}" >/dev/null 2>&1 || true "${FORGE_API}/issues/${issue}/labels/${bl_id}" >/dev/null 2>&1 || true
fi fi
_ilc_log "claimed issue #${issue}" _ilc_log "claimed issue #${issue}"
return 0
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# issue_release — remove "in-progress" label, add "backlog" label, clear assignee. # issue_release — remove "in-progress" label, add "backlog" label.
# Args: issue_number # Args: issue_number
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
issue_release() { issue_release() {
local issue="$1" 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 local ip_id bl_id
ip_id=$(_ilc_in_progress_id) ip_id=$(_ilc_in_progress_id)
bl_id=$(_ilc_backlog_id) bl_id=$(_ilc_backlog_id)
@ -161,27 +128,6 @@ issue_release() {
_ilc_log "released issue #${issue}" _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. # issue_block — add "blocked" label, post diagnostic comment, remove in-progress.
# Args: issue_number reason [result_text] # Args: issue_number reason [result_text]
@ -208,9 +154,14 @@ issue_block() {
fi fi
} > "$tmpfile" } > "$tmpfile"
# Post comment using shared helper # Post comment
_ilc_post_comment "$issue" "$(cat "$tmpfile")" jq -Rs '{body:.}' < "$tmpfile" > "${tmpfile}.json"
rm -f "$tmpfile" 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 || true
rm -f "$tmpfile" "${tmpfile}.json"
# Remove in-progress, add blocked # Remove in-progress, add blocked
local ip_id bk_id local ip_id bk_id
@ -233,19 +184,11 @@ issue_block() {
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# issue_close — clear assignee, PATCH state to closed. # issue_close — PATCH state to closed.
# Args: issue_number # Args: issue_number
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
issue_close() { issue_close() {
local issue="$1" 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 \ curl -sf -X PATCH \
-H "Authorization: token ${FORGE_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \

View file

@ -83,7 +83,7 @@ if mirrors:
# Export parsed variables. # Export parsed variables.
# Inside the agents container (DISINTO_CONTAINER=1), compose already sets the # Inside the agents container (DISINTO_CONTAINER=1), compose already sets the
# correct FORGE_URL (http://forgejo:3000) and path vars for the container # correct FORGE_URL (http://forgejo:3000) and path vars for the container
# environment. The TOML carries host-perspective values (localhost, /home/admin/…) # environment. The TOML carries host-perspective values (localhost, /home/johba/…)
# that would break container API calls and path resolution. Skip overriding # that would break container API calls and path resolution. Skip overriding
# any env var that is already set when running inside the container. # any env var that is already set when running inside the container.
while IFS='=' read -r _key _val; do while IFS='=' read -r _key _val; do
@ -100,8 +100,6 @@ export FORGE_URL="${FORGE_URL:-http://localhost:3000}"
if [ -n "$FORGE_REPO" ]; then if [ -n "$FORGE_REPO" ]; then
export FORGE_API="${FORGE_URL}/api/v1/repos/${FORGE_REPO}" export FORGE_API="${FORGE_URL}/api/v1/repos/${FORGE_REPO}"
export FORGE_WEB="${FORGE_URL}/${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 fi
# Backwards-compat aliases # Backwards-compat aliases
export CODEBERG_REPO="${FORGE_REPO}" export CODEBERG_REPO="${FORGE_REPO}"

View file

@ -13,16 +13,7 @@ mirror_push() {
local name url local name url
for name in $MIRROR_NAMES; do for name in $MIRROR_NAMES; do
# Convert name to uppercase env var name safely (only alphanumeric allowed) url=$(eval "echo \"\$MIRROR_$(echo "$name" | tr '[:lower:]' '[:upper:]')\"") || true
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:-}"
[ -z "$url" ] && continue [ -z "$url" ] && continue
# Ensure remote exists with correct URL # Ensure remote exists with correct URL

View file

@ -61,15 +61,13 @@ _prl_log() {
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# pr_create — Create a PR via forge API. # pr_create — Create a PR via forge API.
# Args: branch title body [base_branch] [api_url] # Args: branch title body [base_branch]
# Stdout: PR number # Stdout: PR number
# Returns: 0=created (or found existing), 1=failed # Returns: 0=created (or found existing), 1=failed
# api_url defaults to FORGE_API if not provided
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
pr_create() { pr_create() {
local branch="$1" title="$2" body="$3" local branch="$1" title="$2" body="$3"
local base="${4:-${PRIMARY_BRANCH:-main}}" local base="${4:-${PRIMARY_BRANCH:-main}}"
local api_url="${5:-${FORGE_API}}"
local tmpfile resp http_code resp_body pr_num local tmpfile resp http_code resp_body pr_num
tmpfile=$(mktemp /tmp/prl-create-XXXXXX.json) tmpfile=$(mktemp /tmp/prl-create-XXXXXX.json)
@ -79,7 +77,7 @@ pr_create() {
resp=$(curl -s -w "\n%{http_code}" -X POST \ resp=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token ${FORGE_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${api_url}/pulls" \ "${FORGE_API}/pulls" \
--data-binary @"$tmpfile") || true --data-binary @"$tmpfile") || true
rm -f "$tmpfile" rm -f "$tmpfile"
@ -94,7 +92,7 @@ pr_create() {
return 0 return 0
;; ;;
409) 409)
pr_num=$(pr_find_by_branch "$branch" "$api_url") || true pr_num=$(pr_find_by_branch "$branch") || true
if [ -n "$pr_num" ]; then if [ -n "$pr_num" ]; then
_prl_log "PR already exists: #${pr_num}" _prl_log "PR already exists: #${pr_num}"
printf '%s' "$pr_num" printf '%s' "$pr_num"
@ -112,17 +110,15 @@ pr_create() {
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# pr_find_by_branch — Find an open PR by head branch name. # pr_find_by_branch — Find an open PR by head branch name.
# Args: branch [api_url] # Args: branch
# Stdout: PR number # Stdout: PR number
# Returns: 0=found, 1=not found # Returns: 0=found, 1=not found
# api_url defaults to FORGE_API if not provided
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
pr_find_by_branch() { pr_find_by_branch() {
local branch="$1" local branch="$1"
local api_url="${2:-${FORGE_API}}"
local pr_num local pr_num
pr_num=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ pr_num=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${api_url}/pulls?state=open&limit=20" | \ "${FORGE_API}/pulls?state=open&limit=20" | \
jq -r --arg b "$branch" '.[] | select(.head.ref == $b) | .number' \ jq -r --arg b "$branch" '.[] | select(.head.ref == $b) | .number' \
| head -1) || true | head -1) || true
if [ -n "$pr_num" ]; then if [ -n "$pr_num" ]; then
@ -348,22 +344,6 @@ pr_is_merged() {
[ "$merged" = "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}"
curl -sf -X PATCH \
-H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \
"${FORGE_API}/pulls/${pr_num}" \
-d '{"state":"closed"}' >/dev/null 2>&1 || true
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# pr_walk_to_merge — Walk a PR through CI, review, and merge. # pr_walk_to_merge — Walk a PR through CI, review, and merge.
# #
@ -414,23 +394,6 @@ pr_walk_to_merge() {
fi fi
_prl_log "CI failed — invoking agent (attempt ${ci_fix_count}/${max_ci_fixes})" _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" \ agent_run --resume "$session_id" --worktree "$worktree" \
"CI failed on PR #${pr_num} (attempt ${ci_fix_count}/${max_ci_fixes}). "CI failed on PR #${pr_num} (attempt ${ci_fix_count}/${max_ci_fixes}).
@ -438,7 +401,7 @@ Pipeline: #${_PR_CI_PIPELINE:-?}
Failure type: ${_PR_CI_FAILURE_TYPE:-unknown} Failure type: ${_PR_CI_FAILURE_TYPE:-unknown}
Error log: Error log:
${_PR_CI_ERROR_LOG:-No logs available.}${logs_section} ${_PR_CI_ERROR_LOG:-No logs available.}
Fix the issue, run tests, commit, rebase on ${PRIMARY_BRANCH}, and push: Fix the issue, run tests, commit, rebase on ${PRIMARY_BRANCH}, and push:
git fetch ${remote} ${PRIMARY_BRANCH} && git rebase ${remote}/${PRIMARY_BRANCH} git fetch ${remote} ${PRIMARY_BRANCH} && git rebase ${remote}/${PRIMARY_BRANCH}

View file

@ -1,210 +0,0 @@
#!/usr/bin/env bash
# profile.sh — Helpers for agent .profile repo management
#
# Source after lib/env.sh and lib/formula-session.sh:
# source "$(dirname "$0")/../lib/env.sh"
# source "$(dirname "$0")/lib/formula-session.sh"
# source "$(dirname "$0")/lib/profile.sh"
#
# Required globals: FORGE_TOKEN, FORGE_URL, AGENT_IDENTITY, PROFILE_REPO_PATH
#
# Functions:
# profile_propose_formula NEW_FORMULA CONTENT REASON — create PR to update formula.toml
set -euo pipefail
# Internal log helper
_profile_log() {
if declare -f log >/dev/null 2>&1; then
log "profile: $*"
else
printf '[%s] profile: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >&2
fi
}
# -----------------------------------------------------------------------------
# profile_propose_formula — Propose a formula change via PR
#
# Creates a branch, writes updated formula.toml, opens a PR, and returns PR number.
# Branch is protected (requires admin approval per #87).
#
# Args:
# $1 - NEW_FORMULA_CONTENT: The complete new formula.toml content
# $2 - REASON: Human-readable explanation of what changed and why
#
# Returns:
# 0 on success, prints PR number to stdout
# 1 on failure
#
# Example:
# source "$(dirname "$0")/../lib/env.sh"
# source "$(dirname "$0")/lib/formula-session.sh"
# source "$(dirname "$0")/lib/profile.sh"
# AGENT_IDENTITY="dev-bot"
# ensure_profile_repo "$AGENT_IDENTITY"
# profile_propose_formula "$new_formula" "Added new prompt pattern for code review"
# -----------------------------------------------------------------------------
profile_propose_formula() {
local new_formula="$1"
local reason="$2"
if [ -z "${AGENT_IDENTITY:-}" ]; then
_profile_log "ERROR: AGENT_IDENTITY not set"
return 1
fi
if [ -z "${PROFILE_REPO_PATH:-}" ]; then
_profile_log "ERROR: PROFILE_REPO_PATH not set — ensure_profile_repo not called"
return 1
fi
if [ -z "${FORGE_TOKEN:-}" ]; then
_profile_log "ERROR: FORGE_TOKEN not set"
return 1
fi
if [ -z "${FORGE_URL:-}" ]; then
_profile_log "ERROR: FORGE_URL not set"
return 1
fi
# Generate short description from reason for branch name
local short_desc
short_desc=$(printf '%s' "$reason" | \
tr '[:upper:]' '[:lower:]' | \
sed 's/[^a-z0-9 ]//g' | \
sed 's/ */ /g' | \
sed 's/^ *//;s/ *$//' | \
cut -c1-40 | \
tr ' ' '-')
if [ -z "$short_desc" ]; then
short_desc="formula-update"
fi
local branch_name="formula/${short_desc}"
local formula_path="${PROFILE_REPO_PATH}/formula.toml"
_profile_log "Proposing formula change: ${branch_name}"
_profile_log "Reason: ${reason}"
# Ensure we're on main branch and up-to-date
_profile_log "Fetching .profile repo"
(
cd "$PROFILE_REPO_PATH" || return 1
git fetch origin main --quiet 2>/dev/null || \
git fetch origin master --quiet 2>/dev/null || true
# Reset to main/master
if git checkout main --quiet 2>/dev/null; then
git pull --ff-only origin main --quiet 2>/dev/null || true
elif git checkout master --quiet 2>/dev/null; then
git pull --ff-only origin master --quiet 2>/dev/null || true
else
_profile_log "ERROR: Failed to checkout main/master branch"
return 1
fi
# Create and checkout new branch
git checkout -b "$branch_name" 2>/dev/null || {
_profile_log "Branch ${branch_name} may already exist"
git checkout "$branch_name" 2>/dev/null || return 1
}
# Write formula.toml
printf '%s' "$new_formula" > "$formula_path"
# Commit the change
git config user.name "${AGENT_IDENTITY}" || true
git config user.email "${AGENT_IDENTITY}@users.noreply.codeberg.org" || true
git add "$formula_path"
git commit -m "formula: ${reason}" --no-verify || {
_profile_log "No changes to commit (formula unchanged)"
# Check if branch has any commits
if git rev-parse HEAD >/dev/null 2>&1; then
: # branch has commits, continue
else
_profile_log "ERROR: Failed to create commit"
return 1
fi
}
# Push branch
local remote="${FORGE_REMOTE:-origin}"
git push --set-upstream "$remote" "$branch_name" --quiet 2>/dev/null || {
_profile_log "ERROR: Failed to push branch"
return 1
}
_profile_log "Branch pushed: ${branch_name}"
# Create PR
local forge_url="${FORGE_URL%/}"
local api_url="${forge_url}/api/v1/repos/${AGENT_IDENTITY}/.profile"
local primary_branch="main"
# Check if main or master is the primary branch
if ! curl -sf -o /dev/null -w "%{http_code}" \
-H "Authorization: token ${FORGE_TOKEN}" \
"${api_url}/git/branches/main" 2>/dev/null | grep -q "200"; then
primary_branch="master"
fi
local pr_title="formula: ${reason}"
local pr_body="# Formula Update
**Reason:** ${reason}
---
*This PR was auto-generated by ${AGENT_IDENTITY}.*
"
local pr_response http_code
local pr_json
pr_json=$(jq -n \
--arg t "$pr_title" \
--arg b "$pr_body" \
--arg h "$branch_name" \
--arg base "$primary_branch" \
'{title:$t, body:$b, head:$h, base:$base}') || {
_profile_log "ERROR: Failed to build PR JSON"
return 1
}
pr_response=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \
"${api_url}/pulls" \
-d "$pr_json" || true)
http_code=$(printf '%s\n' "$pr_response" | tail -1)
pr_response=$(printf '%s\n' "$pr_response" | sed '$d')
if [ "$http_code" = "201" ] || [ "$http_code" = "200" ]; then
local pr_num
pr_num=$(printf '%s' "$pr_response" | jq -r '.number')
_profile_log "PR created: #${pr_num}"
printf '%s' "$pr_num"
return 0
else
# Check if PR already exists (409 conflict)
if [ "$http_code" = "409" ]; then
local existing_pr
existing_pr=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${api_url}/pulls?state=open&head=${AGENT_IDENTITY}:formula/${short_desc}" 2>/dev/null | \
jq -r '.[0].number // empty') || true
if [ -n "$existing_pr" ]; then
_profile_log "PR already exists: #${existing_pr}"
printf '%s' "$existing_pr"
return 0
fi
fi
_profile_log "ERROR: Failed to create PR (HTTP ${http_code})"
return 1
fi
)
return $?
}

View file

@ -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
}

View file

@ -1,4 +1,4 @@
<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 --> <!-- last-reviewed: f32707ba659de278a3af434e3549fb8a8dce9d3a -->
# Planner Agent # Planner Agent
**Role**: Strategic planning using a Prerequisite Tree (Theory of Constraints), **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 need human decisions or external resources are filed as vault procurement items
(`$OPS_REPO_ROOT/vault/pending/*.md`) instead of being escalated. Phase 3 (`$OPS_REPO_ROOT/vault/pending/*.md`) instead of being escalated. Phase 3
(file-at-constraints): identify the top 3 unresolved prerequisites that block (file-at-constraints): identify the top 3 unresolved prerequisites that block
the most downstream objectives — file issues using a **template-or-vision gate**: the most downstream objectives — file issues as either `backlog` (code changes,
read issue templates from `.codeberg/ISSUE_TEMPLATE/*.yaml`, attempt to fill dev-agent) or `action` (run existing formula, action-agent). **Stuck issues
template fields (affected_files ≤3, acceptance_criteria ≤5, single clear approach), (detected BOUNCED/LABEL_CHURN) are dispatched to the `groom-backlog` formula
then apply complexity test: if work touches one subsystem with no design forks, in breakdown mode instead of being re-promoted** — this breaks the ping-pong
file as `backlog` using matching template (bug/feature/refactor); otherwise loop by splitting them into dev-agent-sized sub-issues. **Human-blocked issues
label `vision` with problem statement and why it's vision-sized. **Human-blocked are routed through the vault** — the planner files an actionable procurement
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 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. will then sections) and marks the prerequisite as blocked-on-vault in the tree.
Deduplication: checks pending/ + approved/ + fired/ before creating. Deduplication: checks pending/ + approved/ + fired/ before creating.
@ -57,15 +56,15 @@ component, not work.
prediction-triage, update-prerequisite-tree, file-at-constraints, prediction-triage, update-prerequisite-tree, file-at-constraints,
journal-and-memory, commit-and-pr) with `needs` dependencies. Claude journal-and-memory, commit-and-pr) with `needs` dependencies. Claude
executes all steps in a single interactive session with tool access executes all steps in a single interactive session with tool access
- `formulas/groom-backlog.toml`Grooming formula for backlog triage and - `formulas/groom-backlog.toml`Dual-mode formula: grooming (default) or
grooming. (Note: the planner no longer dispatches breakdown mode — complex breakdown (dispatched by planner for bounced/stuck issues — splits the issue
issues are labeled `vision` instead.) into dev-agent-sized sub-issues, removes `underspecified` label)
- `$OPS_REPO_ROOT/prerequisites.md` — Prerequisite tree: versioned constraint - `$OPS_REPO_ROOT/prerequisites.md` — Prerequisite tree: versioned constraint
map linking VISION.md objectives to their prerequisites. Planner owns the map linking VISION.md objectives to their prerequisites. Planner owns the
tree, humans steer by editing VISION.md. Tree grows organically as the tree, humans steer by editing VISION.md. Tree grows organically as the
planner discovers new prerequisites during runs planner discovers new prerequisites during runs
- `$OPS_REPO_ROOT/knowledge/planner-memory.md` — Persistent memory across runs (in ops repo) - `$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 **Constraint focus**: The planner uses Theory of Constraints to avoid premature
issue filing. Only the top 3 unresolved prerequisites that block the most issue filing. Only the top 3 unresolved prerequisites that block the most

View file

@ -52,14 +52,8 @@ check_memory 2000
log "--- Planner run start ---" log "--- Planner run start ---"
# ── Resolve agent identity for .profile repo ────────────────────────────
if [ -z "${AGENT_IDENTITY:-}" ] && [ -n "${FORGE_PLANNER_TOKEN:-}" ]; then
AGENT_IDENTITY=$(curl -sf -H "Authorization: token ${FORGE_PLANNER_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 + 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_context_block VISION.md AGENTS.md ops:RESOURCES.md ops:prerequisites.md
# ── Build structural analysis graph ────────────────────────────────────── # ── Build structural analysis graph ──────────────────────────────────────
@ -78,8 +72,24 @@ $(cat "$MEMORY_FILE")
" "
fi fi
# ── Prepare .profile context (lessons injection) ───────────────────────── # ── Read recent journal files ──────────────────────────────────────────
formula_prepare_profile_context 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) ─────────────────────────────── # ── Read scratch file (compaction survival) ───────────────────────────────
SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE") SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
@ -95,7 +105,7 @@ build_sdk_prompt_footer "
PROMPT="You are the strategic planner for ${FORGE_REPO}. Work through the formula below. PROMPT="You are the strategic planner for ${FORGE_REPO}. Work through the formula below.
## Project context ## Project context
${CONTEXT_BLOCK}${MEMORY_BLOCK}$(formula_lessons_block) ${CONTEXT_BLOCK}${MEMORY_BLOCK}${JOURNAL_BLOCK}
${GRAPH_SECTION} ${GRAPH_SECTION}
${SCRATCH_CONTEXT:+${SCRATCH_CONTEXT} ${SCRATCH_CONTEXT:+${SCRATCH_CONTEXT}
} }
@ -115,8 +125,5 @@ export CLAUDE_MODEL="opus"
agent_run --worktree "$WORKTREE" "$PROMPT" agent_run --worktree "$WORKTREE" "$PROMPT"
log "agent_run complete" 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" rm -f "$SCRATCH_FILE"
log "--- Planner run done ---" log "--- Planner run done ---"

View file

@ -1,4 +1,4 @@
<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 --> <!-- last-reviewed: f32707ba659de278a3af434e3549fb8a8dce9d3a -->
# Predictor Agent # Predictor Agent
**Role**: Abstract adversary (the "goblin"). Runs a 2-step formula **Role**: Abstract adversary (the "goblin"). Runs a 2-step formula

View file

@ -53,22 +53,13 @@ check_memory 2000
log "--- Predictor run start ---" log "--- Predictor run start ---"
# ── Resolve agent identity for .profile repo ────────────────────────────
if [ -z "${AGENT_IDENTITY:-}" ] && [ -n "${FORGE_PREDICTOR_TOKEN:-}" ]; then
AGENT_IDENTITY=$(curl -sf -H "Authorization: token ${FORGE_PREDICTOR_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 + 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_context_block AGENTS.md ops:RESOURCES.md VISION.md ops:prerequisites.md
# ── Build structural analysis graph ────────────────────────────────────── # ── Build structural analysis graph ──────────────────────────────────────
build_graph_section build_graph_section
# ── Prepare .profile context (lessons injection) ─────────────────────────
formula_prepare_profile_context
# ── Read scratch file (compaction survival) ─────────────────────────────── # ── Read scratch file (compaction survival) ───────────────────────────────
SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE") SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE") SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
@ -91,10 +82,9 @@ Use WebSearch for external signal scanning — be targeted (project dependencies
and tools only, not general news). Limit to 3 web searches per run. and tools only, not general news). Limit to 3 web searches per run.
## Project context ## Project context
${CONTEXT_BLOCK}$(formula_lessons_block) ${CONTEXT_BLOCK}
${GRAPH_SECTION} ${GRAPH_SECTION}
${SCRATCH_CONTEXT:+${SCRATCH_CONTEXT} ${SCRATCH_CONTEXT}
}
## Formula ## Formula
${FORMULA_CONTENT} ${FORMULA_CONTENT}
@ -108,8 +98,5 @@ formula_worktree_setup "$WORKTREE"
agent_run --worktree "$WORKTREE" "$PROMPT" agent_run --worktree "$WORKTREE" "$PROMPT"
log "agent_run complete" 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" rm -f "$SCRATCH_FILE"
log "--- Predictor run done ---" log "--- Predictor run done ---"

View file

@ -5,7 +5,7 @@
name = "disinto" name = "disinto"
repo = "johba/disinto" repo = "johba/disinto"
ops_repo = "disinto-admin/disinto-ops" ops_repo = "johba/disinto-ops"
forge_url = "http://localhost:3000" forge_url = "http://localhost:3000"
repo_root = "/home/YOU/dark-factory" repo_root = "/home/YOU/dark-factory"
ops_repo_root = "/home/YOU/disinto-ops" ops_repo_root = "/home/YOU/disinto-ops"

View file

@ -1,4 +1,4 @@
<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 --> <!-- last-reviewed: f32707ba659de278a3af434e3549fb8a8dce9d3a -->
# Review Agent # Review Agent
**Role**: AI-powered PR review — post structured findings and formal **Role**: AI-powered PR review — post structured findings and formal
@ -9,7 +9,7 @@ whose CI has passed and that lack a review for the current HEAD SHA, then
spawns `review-pr.sh <pr-number>`. spawns `review-pr.sh <pr-number>`.
**Key files**: **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-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.
- `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. - `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**: **Environment variables consumed**:

View file

@ -166,20 +166,6 @@ while IFS= read -r line; do
log " #${PR_NUM} needs review (CI=success, SHA=${PR_SHA:0:7})" 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}"
if "${SCRIPT_DIR}/review-pr.sh" "$PR_NUM" 2>&1; then if "${SCRIPT_DIR}/review-pr.sh" "$PR_NUM" 2>&1; then
REVIEWED=$((REVIEWED + 1)) REVIEWED=$((REVIEWED + 1))
else else

View file

@ -27,8 +27,6 @@ source "$(dirname "$0")/../lib/env.sh"
source "$(dirname "$0")/../lib/ci-helpers.sh" source "$(dirname "$0")/../lib/ci-helpers.sh"
source "$(dirname "$0")/../lib/worktree.sh" source "$(dirname "$0")/../lib/worktree.sh"
source "$(dirname "$0")/../lib/agent-sdk.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 # Auto-pull factory code to pick up merged fixes before any logic runs
git -C "$FACTORY_ROOT" pull --ff-only origin main 2>/dev/null || true git -C "$FACTORY_ROOT" pull --ff-only origin main 2>/dev/null || true
@ -58,14 +56,6 @@ if [ -f "$LOGFILE" ] && [ "$(stat -c%s "$LOGFILE" 2>/dev/null || echo 0)" -gt 10
mv "$LOGFILE" "$LOGFILE.old" mv "$LOGFILE" "$LOGFILE.old"
fi fi
# =============================================================================
# RESOLVE AGENT IDENTITY FOR .PROFILE REPO
# =============================================================================
if [ -z "${AGENT_IDENTITY:-}" ] && [ -n "${FORGE_TOKEN:-}" ]; then
AGENT_IDENTITY=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_URL:-http://localhost:3000}/api/v1/user" 2>/dev/null | jq -r '.login // empty' 2>/dev/null || true)
fi
# ============================================================================= # =============================================================================
# MEMORY GUARD # MEMORY GUARD
# ============================================================================= # =============================================================================
@ -190,11 +180,6 @@ else
log "WARN: build-graph.py failed — continuing without structural analysis" log "WARN: build-graph.py failed — continuing without structural analysis"
fi fi
# =============================================================================
# LOAD LESSONS FROM .PROFILE REPO (PRE-SESSION)
# =============================================================================
formula_prepare_profile_context
# ============================================================================= # =============================================================================
# BUILD PROMPT # BUILD PROMPT
# ============================================================================= # =============================================================================
@ -208,7 +193,6 @@ FORMULA=$(cat "${FACTORY_ROOT}/formulas/review-pr.toml")
"$PR_BODY" "$FILES" "$DNOTE" "$DIFF" "$PR_BODY" "$FILES" "$DNOTE" "$DIFF"
[ -n "$PREV_CONTEXT" ] && printf '%s\n' "$PREV_CONTEXT" [ -n "$PREV_CONTEXT" ] && printf '%s\n' "$PREV_CONTEXT"
[ -n "$GRAPH_SECTION" ] && printf '%s\n' "$GRAPH_SECTION" [ -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' \ 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" "$FORMULA" "$OUTPUT_FILE" "$API" "$PR_NUMBER" "$FACTORY_ROOT"
printf 'NEVER echo the actual token — always reference ${FORGE_TOKEN} or ${FORGE_REVIEW_TOKEN}.\n' printf 'NEVER echo the actual token — always reference ${FORGE_TOKEN} or ${FORGE_REVIEW_TOKEN}.\n'
@ -314,7 +298,4 @@ case "$VERDICT" in
;; ;;
esac 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})" log "DONE: ${VERDICT} (re-review: ${IS_RE_REVIEW})"

View file

@ -188,7 +188,7 @@ collect_agent_metrics() {
local agent_name log_path age_min last_active local agent_name log_path age_min last_active
for log_entry in dev/dev-agent.log review/review.log gardener/gardener.log \ for log_entry in dev/dev-agent.log review/review.log gardener/gardener.log \
planner/planner.log predictor/predictor.log supervisor/supervisor.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")") agent_name=$(basename "$(dirname "$log_entry")")
log_path="${FACTORY_ROOT}/${log_entry}" log_path="${FACTORY_ROOT}/${log_entry}"
if [ -f "$log_path" ]; then if [ -f "$log_path" ]; then

View file

@ -397,10 +397,15 @@
<div class="role">Detects <strong>infrastructure patterns</strong> &mdash; recurring failures, resource trends, emerging issues. Files predictions for triage.</div> <div class="role">Detects <strong>infrastructure patterns</strong> &mdash; recurring failures, resource trends, emerging issues. Files predictions for triage.</div>
<div class="trigger">Cron: daily</div> <div class="trigger">Cron: daily</div>
</div> </div>
<div class="agent-card">
<div class="name">action-agent</div>
<div class="role">Executes <strong>operational tasks</strong> defined as formulas &mdash; site deployments, data migrations, any multi-step procedure.</div>
<div class="trigger">Cron: every 5 min</div>
</div>
<div class="agent-card"> <div class="agent-card">
<div class="name">vault</div> <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="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">Redesign in progress</div> <div class="trigger">Event-driven</div>
</div> </div>
</div> </div>
</div> </div>
@ -446,11 +451,12 @@
<!-- Vault --> <!-- Vault -->
<div class="section"> <div class="section">
<h2>Vault &mdash; being redesigned</h2> <h2>Vault &mdash; quality gate</h2>
<div class="concept"> <div class="concept">
<div class="label">Redesign in progress</div> <div class="label">How it works</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>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>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> <p><strong>Auto-approve</strong> &mdash; safe, well-understood operations pass through instantly. <strong>Escalate</strong> &mdash; risky or novel operations get sent to a human via Matrix. <strong>Reject</strong> &mdash; 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>
</div> </div>
@ -518,7 +524,8 @@ disinto/
├── <span class="agent-name">predictor/</span> predictor-run.sh (daily cron executor) ├── <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">planner/</span> planner-run.sh (weekly cron executor)
├── <span class="agent-name">supervisor/</span> supervisor-run.sh (health monitoring) ├── <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">lib/</span> env.sh, agent-session.sh, ci-helpers.sh
├── <span class="agent-name">projects/</span> *.toml per-project config ├── <span class="agent-name">projects/</span> *.toml per-project config
├── <span class="agent-name">formulas/</span> TOML specs for multi-step agent tasks ├── <span class="agent-name">formulas/</span> TOML specs for multi-step agent tasks

350
skill/SKILL.md Normal file
View 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
View 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
View 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
View 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

View file

@ -0,0 +1,21 @@
## Summary
<!-- One or two sentences: what and why -->
## Acceptance criteria
- [ ] <!-- Criterion 1 -->
- [ ] <!-- Criterion 2 -->
- [ ] <!-- Criterion 3 -->
## Affected files
<!-- List files/directories this issue will touch -->
- `path/to/file.sh`
## Dependencies
<!-- List issue numbers this depends on, or "None" -->
None

View file

@ -1,4 +1,4 @@
<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 --> <!-- last-reviewed: f32707ba659de278a3af434e3549fb8a8dce9d3a -->
# Supervisor Agent # Supervisor Agent
**Role**: Health monitoring and auto-remediation, executed as a formula-driven **Role**: Health monitoring and auto-remediation, executed as a formula-driven
@ -31,6 +31,8 @@ runs directly from cron like the planner and predictor.
- `formulas/run-supervisor.toml` — Execution spec: five steps (preflight review, - `formulas/run-supervisor.toml` — Execution spec: five steps (preflight review,
health-assessment, decide-actions, report, journal) with `needs` dependencies. health-assessment, decide-actions, report, journal) with `needs` dependencies.
Claude evaluates all metrics and takes actions in a single interactive session Claude evaluates all metrics and takes actions in a single interactive session
- `$OPS_REPO_ROOT/journal/supervisor/*.md` — Daily health logs from each supervisor run
- `supervisor/PROMPT.md` — Best-practices reference for remediation actions
- `$OPS_REPO_ROOT/knowledge/*.md` — Domain-specific remediation guides (memory, - `$OPS_REPO_ROOT/knowledge/*.md` — Domain-specific remediation guides (memory,
disk, CI, git, dev-agent, review-agent, forge) disk, CI, git, dev-agent, review-agent, forge)
- `supervisor/supervisor-poll.sh` — Legacy bash orchestrator (superseded by - `supervisor/supervisor-poll.sh` — Legacy bash orchestrator (superseded by

118
supervisor/PROMPT.md Normal file
View file

@ -0,0 +1,118 @@
# Supervisor Agent
You are the supervisor agent for `$FORGE_REPO`. You were called because
`supervisor-poll.sh` detected an issue it couldn't auto-fix.
## Priority Order
1. **P0 — Memory crisis:** RAM <500MB or swap >3GB
2. **P1 — Disk pressure:** Disk >80%
3. **P2 — Factory stopped:** Dev-agent dead, CI down, git broken, all backlog dep-blocked
4. **P3 — Factory degraded:** Derailed PR, stuck pipeline, unreviewed PRs, circular deps, stale deps
5. **P4 — Housekeeping:** Stale processes, log rotation
## What You Can Do
Fix the issue yourself. You have full shell access and `--dangerously-skip-permissions`.
Before acting, read the relevant knowledge file from the ops repo:
- Memory issues → `cat ${OPS_REPO_ROOT}/knowledge/memory.md`
- Disk issues → `cat ${OPS_REPO_ROOT}/knowledge/disk.md`
- CI issues → `cat ${OPS_REPO_ROOT}/knowledge/ci.md`
- forge / rate limits → `cat ${OPS_REPO_ROOT}/knowledge/forge.md`
- Dev-agent issues → `cat ${OPS_REPO_ROOT}/knowledge/dev-agent.md`
- Review-agent issues → `cat ${OPS_REPO_ROOT}/knowledge/review-agent.md`
- Git issues → `cat ${OPS_REPO_ROOT}/knowledge/git.md`
## Credentials & API Access
Environment variables are set. Source the helper library for convenience functions:
```bash
source ${FACTORY_ROOT}/lib/env.sh
```
This gives you:
- `forge_api GET "/pulls?state=open"` — forge API (uses $FORGE_TOKEN)
- `wpdb -c "SELECT ..."` — Woodpecker Postgres (uses $WOODPECKER_DB_PASSWORD)
- `woodpecker_api "/repos/$WOODPECKER_REPO_ID/pipelines"` — Woodpecker REST API (uses $WOODPECKER_TOKEN)
- `$FORGE_REVIEW_TOKEN` — for posting reviews as the review_bot account
- `$PROJECT_REPO_ROOT` — path to the target project repo
- `$PROJECT_NAME` — short project name (for worktree prefixes, container names)
- `$PRIMARY_BRANCH` — main branch (master or main)
- `$FACTORY_ROOT` — path to the disinto repo
## Handling Dependency Alerts
### Circular dependencies (P3)
When you see "Circular dependency deadlock: #A -> #B -> #A", the backlog is permanently
stuck. Your job: figure out the correct dependency direction and fix the wrong one.
1. Read both issue bodies: `forge_api GET "/issues/A"`, `forge_api GET "/issues/B"`
2. Read the referenced source files in `$PROJECT_REPO_ROOT` to understand which change
actually depends on which
3. Edit the issue that has the incorrect dep to remove the `#NNN` reference from its
`## Dependencies` section (replace with `- None` if it was the only dep)
4. If the correct direction is unclear from code, file a vault item with both issue summaries
Use the forge API to edit issue bodies:
```bash
# Read current body
BODY=$(forge_api GET "/issues/NNN" | jq -r '.body')
# Edit (remove the circular ref, keep other deps)
NEW_BODY=$(echo "$BODY" | sed 's/- #XXX/- None/')
forge_api PATCH "/issues/NNN" -d "$(jq -nc --arg b "$NEW_BODY" '{body:$b}')"
```
### Stale dependencies (P3)
When you see "Stale dependency: #A blocked by #B (open N days)", the dep may be
obsolete or misprioritized. Investigate:
1. Check if dep #B is still relevant (read its body, check if the code it targets changed)
2. If the dep is obsolete → remove it from #A's `## Dependencies` section
3. If the dep is still needed → file a vault item, suggesting to prioritize #B or split #A
### Dev-agent blocked (P2)
When you see "Dev-agent blocked: last N polls all report 'no ready issues'":
1. Check if circular deps exist (they'll appear as separate P3 alerts)
2. Check if all backlog issues depend on a single unmerged issue — if so, file a vault
item to prioritize that blocker
3. If no clear blocker, file a vault item with the list of blocked issues and their deps
## When you cannot fix it
File a vault procurement item so the human is notified through the vault:
```bash
cat > "${OPS_REPO_ROOT}/vault/pending/supervisor-$(date -u +%Y%m%d-%H%M)-issue.md" <<'VAULT_EOF'
# <What is needed>
## What
<description of the problem and why the supervisor cannot fix it>
## Why
<impact on factory health>
## Unblocks
- Factory health: <what this resolves>
VAULT_EOF
```
The vault-poll will notify the human and track the request.
Do NOT talk to the human directly. The vault is the factory's only interface
to the human for resources and approvals. Fix first, report after.
## Output
```
FIXED: <what you did>
```
or
```
VAULT: filed $OPS_REPO_ROOT/vault/pending/<id>.md — <what's needed>
```
## Learning
If you discover something new, append it to the relevant knowledge file in the ops repo:
```bash
echo "### Lesson title
Description of what you learned." >> "${OPS_REPO_ROOT}/knowledge/<file>.md"
```

View file

@ -132,7 +132,8 @@ echo ""
echo "## Recent Agent Logs" echo "## Recent Agent Logs"
for _log in supervisor/supervisor.log dev/dev-agent.log review/review.log \ for _log in supervisor/supervisor.log dev/dev-agent.log review/review.log \
gardener/gardener.log planner/planner.log predictor/predictor.log; do gardener/gardener.log planner/planner.log predictor/predictor.log \
action/action.log; do
_logpath="${FACTORY_ROOT}/${_log}" _logpath="${FACTORY_ROOT}/${_log}"
if [ -f "$_logpath" ]; then if [ -f "$_logpath" ]; then
_log_age_min=$(( ($(date +%s) - $(stat -c %Y "$_logpath" 2>/dev/null || echo 0)) / 60 )) _log_age_min=$(( ($(date +%s) - $(stat -c %Y "$_logpath" 2>/dev/null || echo 0)) / 60 ))

View file

@ -19,7 +19,7 @@ source "$(dirname "$0")/../lib/ci-helpers.sh"
LOGFILE="${DISINTO_LOG_DIR}/supervisor/supervisor.log" LOGFILE="${DISINTO_LOG_DIR}/supervisor/supervisor.log"
STATUSFILE="/tmp/supervisor-status" STATUSFILE="/tmp/supervisor-status"
LOCKFILE="/tmp/supervisor-poll.lock" LOCKFILE="/tmp/supervisor-poll.lock"
PROMPT_FILE="${FACTORY_ROOT}/formulas/run-supervisor.toml" PROMPT_FILE="${FACTORY_ROOT}/supervisor/PROMPT.md"
PROJECTS_DIR="${FACTORY_ROOT}/projects" PROJECTS_DIR="${FACTORY_ROOT}/projects"
METRICS_FILE="${DISINTO_LOG_DIR}/metrics/supervisor-metrics.jsonl" METRICS_FILE="${DISINTO_LOG_DIR}/metrics/supervisor-metrics.jsonl"

View file

@ -58,12 +58,6 @@ log "--- Supervisor run start ---"
# ── Housekeeping: clean up stale crashed worktrees (>24h) ──────────────── # ── Housekeeping: clean up stale crashed worktrees (>24h) ────────────────
cleanup_stale_crashed_worktrees 24 cleanup_stale_crashed_worktrees 24
# ── Resolve agent identity for .profile repo ────────────────────────────
if [ -z "${AGENT_IDENTITY:-}" ] && [ -n "${FORGE_SUPERVISOR_TOKEN:-}" ]; then
AGENT_IDENTITY=$(curl -sf -H "Authorization: token ${FORGE_SUPERVISOR_TOKEN}" \
"${FORGE_URL:-http://localhost:3000}/api/v1/user" 2>/dev/null | jq -r '.login // empty' 2>/dev/null || true)
fi
# ── Collect pre-flight metrics ──────────────────────────────────────────── # ── Collect pre-flight metrics ────────────────────────────────────────────
log "Running preflight.sh" log "Running preflight.sh"
PREFLIGHT_OUTPUT="" PREFLIGHT_OUTPUT=""
@ -74,12 +68,9 @@ else
fi fi
# ── Load formula + context ─────────────────────────────────────────────── # ── Load formula + context ───────────────────────────────────────────────
load_formula_or_profile "supervisor" "$FACTORY_ROOT/formulas/run-supervisor.toml" || exit 1 load_formula "$FACTORY_ROOT/formulas/run-supervisor.toml"
build_context_block AGENTS.md build_context_block AGENTS.md
# ── Prepare .profile context (lessons injection) ─────────────────────────
formula_prepare_profile_context
# ── Read scratch file (compaction survival) ─────────────────────────────── # ── Read scratch file (compaction survival) ───────────────────────────────
SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE") SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE") SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
@ -100,7 +91,7 @@ Fix what you can. File vault items for what you cannot. Do NOT ask permission
${PREFLIGHT_OUTPUT} ${PREFLIGHT_OUTPUT}
## Project context ## Project context
${CONTEXT_BLOCK}$(formula_lessons_block) ${CONTEXT_BLOCK}
${SCRATCH_CONTEXT:+${SCRATCH_CONTEXT} ${SCRATCH_CONTEXT:+${SCRATCH_CONTEXT}
} }
Priority order: P0 memory > P1 disk > P2 stopped > P3 degraded > P4 housekeeping Priority order: P0 memory > P1 disk > P2 stopped > P3 degraded > P4 housekeeping
@ -114,8 +105,5 @@ ${PROMPT_FOOTER}"
agent_run --worktree "$WORKTREE" "$PROMPT" agent_run --worktree "$WORKTREE" "$PROMPT"
log "agent_run complete" log "agent_run complete"
# Write journal entry post-session
profile_write_journal "supervisor-run" "Supervisor run $(date -u +%Y-%m-%d)" "complete" "" || true
rm -f "$SCRATCH_FILE" rm -f "$SCRATCH_FILE"
log "--- Supervisor run done ---" log "--- Supervisor run done ---"

View file

@ -1,748 +0,0 @@
#!/usr/bin/env python3
"""Mock Forgejo API server for CI smoke tests.
Implements 15 Forgejo API endpoints that disinto init calls.
State stored in-memory (dicts), responds instantly.
"""
import base64
import hashlib
import json
import os
import re
import signal
import socket
import sys
import threading
import uuid
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from urllib.parse import parse_qs, urlparse
# Global state
state = {
"users": {}, # key: username -> user object
"tokens": {}, # key: token_sha1 -> token object
"repos": {}, # key: "owner/repo" -> repo object
"orgs": {}, # key: orgname -> org object
"labels": {}, # key: "owner/repo" -> list of labels
"collaborators": {}, # key: "owner/repo" -> set of usernames
"protections": {}, # key: "owner/repo" -> list of protections
"oauth2_apps": [], # list of oauth2 app objects
}
next_ids = {"users": 1, "tokens": 1, "repos": 1, "orgs": 1, "labels": 1, "oauth2_apps": 1}
SHUTDOWN_REQUESTED = False
def log_request(handler, method, path, status):
"""Log request details."""
print(f"[{handler.log_date_time_string()}] {method} {path} {status}", file=sys.stderr)
def json_response(handler, status, data):
"""Send JSON response."""
body = json.dumps(data).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json")
handler.send_header("Content-Length", len(body))
handler.end_headers()
handler.wfile.write(body)
def basic_auth_user(handler):
"""Extract username from Basic auth header. Returns None if invalid."""
auth_header = handler.headers.get("Authorization", "")
if not auth_header.startswith("Basic "):
return None
try:
decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
username, _ = decoded.split(":", 1)
return username
except Exception:
return None
def token_auth_valid(handler):
"""Check if Authorization header contains token. Doesn't validate value."""
auth_header = handler.headers.get("Authorization", "")
return auth_header.startswith("token ")
def require_token(handler):
"""Require token auth. Return user or None if invalid."""
if not token_auth_valid(handler):
return None
return True # Any token is valid for mock purposes
def require_basic_auth(handler, required_user=None):
"""Require basic auth. Return username or None if invalid."""
username = basic_auth_user(handler)
if username is None:
return None
# Check user exists in state
if username not in state["users"]:
return None
if required_user and username != required_user:
return None
return username
class ForgejoHandler(BaseHTTPRequestHandler):
"""HTTP request handler for mock Forgejo API."""
def log_message(self, format, *args):
"""Override to use our logging."""
pass # We log in do_request
def do_request(self, method):
"""Route request to appropriate handler."""
parsed = urlparse(self.path)
path = parsed.path
query = parse_qs(parsed.query)
log_request(self, method, self.path, "PENDING")
# Strip /api/v1/ prefix for routing (or leading slash for other routes)
route_path = path
if route_path.startswith("/api/v1/"):
route_path = route_path[8:]
elif route_path.startswith("/"):
route_path = route_path.lstrip("/")
# Route to handler
try:
# First try exact match (with / replaced by _)
handler_path = route_path.replace("/", "_")
handler_name = f"handle_{method}_{handler_path}"
handler = getattr(self, handler_name, None)
if handler:
handler(query)
else:
# Try pattern matching for routes with dynamic segments
self._handle_patterned_route(method, route_path, query)
except Exception as e:
log_request(self, method, self.path, 500)
json_response(self, 500, {"message": str(e)})
def _handle_patterned_route(self, method, route_path, query):
"""Handle routes with dynamic segments using pattern matching."""
# Define patterns: (regex, handler_name)
patterns = [
# Users patterns
(r"^users/([^/]+)$", f"handle_{method}_users_username"),
(r"^users/([^/]+)/tokens$", f"handle_{method}_users_username_tokens"),
(r"^users/([^/]+)/repos$", f"handle_{method}_users_username_repos"),
# Repos patterns
(r"^repos/([^/]+)/([^/]+)$", f"handle_{method}_repos_owner_repo"),
(r"^repos/([^/]+)/([^/]+)/labels$", f"handle_{method}_repos_owner_repo_labels"),
(r"^repos/([^/]+)/([^/]+)/branch_protections$", f"handle_{method}_repos_owner_repo_branch_protections"),
(r"^repos/([^/]+)/([^/]+)/collaborators/([^/]+)$", f"handle_{method}_repos_owner_repo_collaborators_collaborator"),
# Org patterns
(r"^orgs/([^/]+)/repos$", f"handle_{method}_orgs_org_repos"),
# User patterns
(r"^user/repos$", f"handle_{method}_user_repos"),
(r"^user/applications/oauth2$", f"handle_{method}_user_applications_oauth2"),
# Admin patterns
(r"^admin/users$", f"handle_{method}_admin_users"),
(r"^admin/users/([^/]+)$", f"handle_{method}_admin_users_username"),
# Org patterns
(r"^orgs$", f"handle_{method}_orgs"),
]
for pattern, handler_name in patterns:
if re.match(pattern, route_path):
handler = getattr(self, handler_name, None)
if handler:
handler(query)
return
self.handle_404()
def do_GET(self):
self.do_request("GET")
def do_POST(self):
self.do_request("POST")
def do_PATCH(self):
self.do_request("PATCH")
def do_PUT(self):
self.do_request("PUT")
def handle_GET_version(self, query):
"""GET /api/v1/version"""
json_response(self, 200, {"version": "11.0.0-mock"})
def handle_GET_users_username(self, query):
"""GET /api/v1/users/{username}"""
# Extract username from path
parts = self.path.split("/")
if len(parts) >= 5:
username = parts[4]
else:
json_response(self, 404, {"message": "user does not exist"})
return
if username in state["users"]:
json_response(self, 200, state["users"][username])
else:
json_response(self, 404, {"message": "user does not exist"})
def handle_GET_users_username_repos(self, query):
"""GET /api/v1/users/{username}/repos"""
if not require_token(self):
json_response(self, 401, {"message": "invalid authentication"})
return
parts = self.path.split("/")
if len(parts) >= 5:
username = parts[4]
else:
json_response(self, 404, {"message": "user not found"})
return
if username not in state["users"]:
json_response(self, 404, {"message": "user not found"})
return
# Return repos owned by this user
user_repos = [r for r in state["repos"].values() if r["owner"]["login"] == username]
json_response(self, 200, user_repos)
def handle_GET_repos_owner_repo(self, query):
"""GET /api/v1/repos/{owner}/{repo}"""
parts = self.path.split("/")
if len(parts) >= 6:
owner = parts[4]
repo = parts[5]
else:
json_response(self, 404, {"message": "repository not found"})
return
key = f"{owner}/{repo}"
if key in state["repos"]:
json_response(self, 200, state["repos"][key])
else:
json_response(self, 404, {"message": "repository not found"})
def handle_GET_repos_owner_repo_labels(self, query):
"""GET /api/v1/repos/{owner}/{repo}/labels"""
parts = self.path.split("/")
if len(parts) >= 6:
owner = parts[4]
repo = parts[5]
else:
json_response(self, 404, {"message": "repository not found"})
return
require_token(self)
key = f"{owner}/{repo}"
if key in state["labels"]:
json_response(self, 200, state["labels"][key])
else:
json_response(self, 200, [])
def handle_GET_user_applications_oauth2(self, query):
"""GET /api/v1/user/applications/oauth2"""
require_token(self)
json_response(self, 200, state["oauth2_apps"])
def handle_GET_mock_shutdown(self, query):
"""GET /mock/shutdown"""
global SHUTDOWN_REQUESTED
SHUTDOWN_REQUESTED = True
json_response(self, 200, {"status": "shutdown"})
def handle_POST_admin_users(self, query):
"""POST /api/v1/admin/users"""
require_token(self)
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode("utf-8")
data = json.loads(body) if body else {}
username = data.get("username")
email = data.get("email")
if not username or not email:
json_response(self, 400, {"message": "username and email are required"})
return
user_id = next_ids["users"]
next_ids["users"] += 1
user = {
"id": user_id,
"login": username,
"email": email,
"full_name": data.get("full_name", ""),
"is_admin": data.get("admin", False),
"must_change_password": data.get("must_change_password", False),
"login_name": data.get("login_name", username),
"visibility": data.get("visibility", "public"),
"avatar_url": f"https://seccdn.libravatar.org/avatar/{hashlib.md5(email.encode()).hexdigest()}",
}
state["users"][username] = user
json_response(self, 201, user)
def handle_GET_users_username_tokens(self, query):
"""GET /api/v1/users/{username}/tokens"""
username = require_token(self)
if not username:
json_response(self, 401, {"message": "invalid authentication"})
return
# Return list of tokens for this user
tokens = [t for t in state["tokens"].values() if t.get("username") == username]
json_response(self, 200, tokens)
def handle_POST_users_username_tokens(self, query):
"""POST /api/v1/users/{username}/tokens"""
username = require_basic_auth(self)
if not username:
json_response(self, 401, {"message": "invalid authentication"})
return
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode("utf-8")
data = json.loads(body) if body else {}
token_name = data.get("name")
if not token_name:
json_response(self, 400, {"message": "name is required"})
return
token_id = next_ids["tokens"]
next_ids["tokens"] += 1
# Deterministic token: sha256(username + name)[:40]
token_str = hashlib.sha256(f"{username}{token_name}".encode()).hexdigest()[:40]
token = {
"id": token_id,
"name": token_name,
"sha1": token_str,
"scopes": data.get("scopes", ["all"]),
"created_at": "2026-04-01T00:00:00Z",
"expires_at": None,
"username": username, # Store username for lookup
}
state["tokens"][token_str] = token
json_response(self, 201, token)
def handle_GET_orgs(self, query):
"""GET /api/v1/orgs"""
if not require_token(self):
json_response(self, 401, {"message": "invalid authentication"})
return
json_response(self, 200, list(state["orgs"].values()))
def handle_POST_orgs(self, query):
"""POST /api/v1/orgs"""
require_token(self)
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode("utf-8")
data = json.loads(body) if body else {}
username = data.get("username")
if not username:
json_response(self, 400, {"message": "username is required"})
return
org_id = next_ids["orgs"]
next_ids["orgs"] += 1
org = {
"id": org_id,
"username": username,
"full_name": username,
"avatar_url": f"https://seccdn.libravatar.org/avatar/{hashlib.md5(username.encode()).hexdigest()}",
"visibility": data.get("visibility", "public"),
}
state["orgs"][username] = org
json_response(self, 201, org)
def handle_POST_orgs_org_repos(self, query):
"""POST /api/v1/orgs/{org}/repos"""
require_token(self)
parts = self.path.split("/")
if len(parts) >= 6:
org = parts[4]
else:
json_response(self, 404, {"message": "organization not found"})
return
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode("utf-8")
data = json.loads(body) if body else {}
repo_name = data.get("name")
if not repo_name:
json_response(self, 400, {"message": "name is required"})
return
repo_id = next_ids["repos"]
next_ids["repos"] += 1
key = f"{org}/{repo_name}"
repo = {
"id": repo_id,
"full_name": key,
"name": repo_name,
"owner": {"id": state["orgs"][org]["id"], "login": org},
"empty": False,
"default_branch": data.get("default_branch", "main"),
"description": data.get("description", ""),
"private": data.get("private", False),
"html_url": f"https://example.com/{key}",
"ssh_url": f"git@example.com:{key}.git",
"clone_url": f"https://example.com/{key}.git",
"created_at": "2026-04-01T00:00:00Z",
}
state["repos"][key] = repo
json_response(self, 201, repo)
def handle_POST_users_username_repos(self, query):
"""POST /api/v1/users/{username}/repos"""
require_token(self)
parts = self.path.split("/")
if len(parts) >= 5:
username = parts[4]
else:
json_response(self, 400, {"message": "username required"})
return
if username not in state["users"]:
json_response(self, 404, {"message": "user not found"})
return
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode("utf-8")
data = json.loads(body) if body else {}
repo_name = data.get("name")
if not repo_name:
json_response(self, 400, {"message": "name is required"})
return
repo_id = next_ids["repos"]
next_ids["repos"] += 1
key = f"{username}/{repo_name}"
repo = {
"id": repo_id,
"full_name": key,
"name": repo_name,
"owner": {"id": state["users"][username]["id"], "login": username},
"empty": not data.get("auto_init", False),
"default_branch": data.get("default_branch", "main"),
"description": data.get("description", ""),
"private": data.get("private", False),
"html_url": f"https://example.com/{key}",
"ssh_url": f"git@example.com:{key}.git",
"clone_url": f"https://example.com/{key}.git",
"created_at": "2026-04-01T00:00:00Z",
}
state["repos"][key] = repo
json_response(self, 201, repo)
def handle_POST_user_repos(self, query):
"""POST /api/v1/user/repos"""
require_token(self)
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode("utf-8")
data = json.loads(body) if body else {}
repo_name = data.get("name")
if not repo_name:
json_response(self, 400, {"message": "name is required"})
return
# Get authenticated user from token
auth_header = self.headers.get("Authorization", "")
token = auth_header.split(" ", 1)[1] if " " in auth_header else ""
# Find user by token (use stored username field)
owner = None
for tok_sha1, tok in state["tokens"].items():
if tok_sha1 == token:
owner = tok.get("username")
break
if not owner:
json_response(self, 401, {"message": "invalid token"})
return
repo_id = next_ids["repos"]
next_ids["repos"] += 1
key = f"{owner}/{repo_name}"
repo = {
"id": repo_id,
"full_name": key,
"name": repo_name,
"owner": {"id": state["users"].get(owner, {}).get("id", 0), "login": owner},
"empty": False,
"default_branch": data.get("default_branch", "main"),
"description": data.get("description", ""),
"private": data.get("private", False),
"html_url": f"https://example.com/{key}",
"ssh_url": f"git@example.com:{key}.git",
"clone_url": f"https://example.com/{key}.git",
"created_at": "2026-04-01T00:00:00Z",
}
state["repos"][key] = repo
json_response(self, 201, repo)
def handle_POST_repos_owner_repo_labels(self, query):
"""POST /api/v1/repos/{owner}/{repo}/labels"""
require_token(self)
parts = self.path.split("/")
if len(parts) >= 6:
owner = parts[4]
repo = parts[5]
else:
json_response(self, 404, {"message": "repository not found"})
return
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode("utf-8")
data = json.loads(body) if body else {}
label_name = data.get("name")
label_color = data.get("color")
if not label_name or not label_color:
json_response(self, 400, {"message": "name and color are required"})
return
label_id = next_ids["labels"]
next_ids["labels"] += 1
key = f"{owner}/{repo}"
label = {
"id": label_id,
"name": label_name,
"color": label_color,
"description": data.get("description", ""),
"url": f"https://example.com/api/v1/repos/{key}/labels/{label_id}",
}
if key not in state["labels"]:
state["labels"][key] = []
state["labels"][key].append(label)
json_response(self, 201, label)
def handle_POST_repos_owner_repo_branch_protections(self, query):
"""POST /api/v1/repos/{owner}/{repo}/branch_protections"""
require_token(self)
parts = self.path.split("/")
if len(parts) >= 6:
owner = parts[4]
repo = parts[5]
else:
json_response(self, 404, {"message": "repository not found"})
return
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode("utf-8")
data = json.loads(body) if body else {}
branch_name = data.get("branch_name", "main")
key = f"{owner}/{repo}"
# Generate unique ID for protection
if key in state["protections"]:
protection_id = len(state["protections"][key]) + 1
else:
protection_id = 1
protection = {
"id": protection_id,
"repo_id": state["repos"].get(key, {}).get("id", 0),
"branch_name": branch_name,
"rule_name": data.get("rule_name", branch_name),
"enable_push": data.get("enable_push", False),
"enable_merge_whitelist": data.get("enable_merge_whitelist", True),
"merge_whitelist_usernames": data.get("merge_whitelist_usernames", ["admin"]),
"required_approvals": data.get("required_approvals", 1),
"apply_to_admins": data.get("apply_to_admins", True),
}
if key not in state["protections"]:
state["protections"][key] = []
state["protections"][key].append(protection)
json_response(self, 201, protection)
def handle_POST_user_applications_oauth2(self, query):
"""POST /api/v1/user/applications/oauth2"""
require_token(self)
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode("utf-8")
data = json.loads(body) if body else {}
app_name = data.get("name")
if not app_name:
json_response(self, 400, {"message": "name is required"})
return
app_id = next_ids["oauth2_apps"]
next_ids["oauth2_apps"] += 1
app = {
"id": app_id,
"name": app_name,
"client_id": str(uuid.uuid4()),
"client_secret": hashlib.sha256(str(uuid.uuid4()).encode()).hexdigest(),
"redirect_uris": data.get("redirect_uris", []),
"confidential_client": data.get("confidential_client", True),
"created_at": "2026-04-01T00:00:00Z",
}
state["oauth2_apps"].append(app)
json_response(self, 201, app)
def handle_PATCH_admin_users_username(self, query):
"""PATCH /api/v1/admin/users/{username}"""
if not require_token(self):
json_response(self, 401, {"message": "invalid authentication"})
return
parts = self.path.split("/")
if len(parts) >= 6:
username = parts[5]
else:
json_response(self, 404, {"message": "user does not exist"})
return
if username not in state["users"]:
json_response(self, 404, {"message": "user does not exist"})
return
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode("utf-8")
data = json.loads(body) if body else {}
user = state["users"][username]
for key, value in data.items():
# Map 'admin' to 'is_admin' for consistency
update_key = 'is_admin' if key == 'admin' else key
if update_key in user:
user[update_key] = value
json_response(self, 200, user)
def handle_PUT_repos_owner_repo_collaborators_collaborator(self, query):
"""PUT /api/v1/repos/{owner}/{repo}/collaborators/{collaborator}"""
require_token(self)
parts = self.path.split("/")
if len(parts) >= 8:
owner = parts[4]
repo = parts[5]
collaborator = parts[7]
else:
json_response(self, 404, {"message": "repository not found"})
return
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode("utf-8")
data = json.loads(body) if body else {}
key = f"{owner}/{repo}"
if key not in state["collaborators"]:
state["collaborators"][key] = set()
state["collaborators"][key].add(collaborator)
self.send_response(204)
self.send_header("Content-Length", 0)
self.end_headers()
def handle_GET_repos_owner_repo_collaborators_collaborator(self, query):
"""GET /api/v1/repos/{owner}/{repo}/collaborators/{collaborator}"""
require_token(self)
parts = self.path.split("/")
if len(parts) >= 8:
owner = parts[4]
repo = parts[5]
collaborator = parts[7]
else:
json_response(self, 404, {"message": "repository not found"})
return
key = f"{owner}/{repo}"
if key in state["collaborators"] and collaborator in state["collaborators"][key]:
self.send_response(204)
self.send_header("Content-Length", 0)
self.end_headers()
else:
json_response(self, 404, {"message": "collaborator not found"})
def handle_404(self):
"""Return 404 for unknown routes."""
json_response(self, 404, {"message": "route not found"})
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
"""Threaded HTTP server for handling concurrent requests."""
daemon_threads = True
def main():
"""Start the mock server."""
global SHUTDOWN_REQUESTED
port = int(os.environ.get("MOCK_FORGE_PORT", 3000))
try:
server = ThreadingHTTPServer(("0.0.0.0", port), ForgejoHandler)
try:
server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
except OSError:
pass # Not all platforms support this
except OSError as e:
print(f"Error: Failed to start server on port {port}: {e}", file=sys.stderr)
sys.exit(1)
print(f"Mock Forgejo server starting on port {port}", file=sys.stderr)
sys.stderr.flush()
def shutdown_handler(signum, frame):
global SHUTDOWN_REQUESTED
SHUTDOWN_REQUESTED = True
# Can't call server.shutdown() directly from signal handler in threaded server
threading.Thread(target=server.shutdown, daemon=True).start()
signal.signal(signal.SIGTERM, shutdown_handler)
signal.signal(signal.SIGINT, shutdown_handler)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.shutdown()
print("Mock Forgejo server stopped", file=sys.stderr)
if __name__ == "__main__":
main()

View file

@ -1,33 +1,32 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# tests/smoke-init.sh — End-to-end smoke test for disinto init with mock Forgejo # tests/smoke-init.sh — End-to-end smoke test for disinto init
# #
# Validates the full init flow using mock Forgejo server: # Expects a running Forgejo at SMOKE_FORGE_URL with a bootstrap admin
# 1. Verify mock Forgejo is ready # user already created (see .woodpecker/smoke-init.yml for CI setup).
# 2. Set up mock binaries (docker, claude, tmux) # Validates the full init flow: Forgejo API, user/token creation,
# 3. Run disinto init # repo setup, labels, TOML generation, and cron installation.
# 4. Verify Forgejo state (users, repo)
# 5. Verify local state (TOML, .env, repo clone)
# 6. Verify cron setup
# #
# Required env: FORGE_URL (default: http://localhost:3000) # Required env: SMOKE_FORGE_URL (default: http://localhost:3000)
# Required tools: bash, curl, jq, python3, git # Required tools: bash, curl, jq, python3, git
set -euo pipefail set -euo pipefail
FACTORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)" FACTORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
FORGE_URL="${FORGE_URL:-http://localhost:3000}" FORGE_URL="${SMOKE_FORGE_URL:-http://localhost:3000}"
MOCK_BIN="/tmp/smoke-mock-bin" SETUP_ADMIN="setup-admin"
SETUP_PASS="SetupPass-789xyz"
TEST_SLUG="smoke-org/smoke-repo" TEST_SLUG="smoke-org/smoke-repo"
MOCK_BIN="/tmp/smoke-mock-bin"
MOCK_STATE="/tmp/smoke-mock-state"
FAILED=0 FAILED=0
fail() { printf 'FAIL: %s\n' "$*" >&2; FAILED=1; } fail() { printf 'FAIL: %s\n' "$*" >&2; FAILED=1; }
pass() { printf 'PASS: %s\n' "$*"; } pass() { printf 'PASS: %s\n' "$*"; }
cleanup() { cleanup() {
# Kill any leftover mock-forgejo.py processes by name rm -rf "$MOCK_BIN" "$MOCK_STATE" /tmp/smoke-test-repo \
pkill -f "mock-forgejo.py" 2>/dev/null || true "${FACTORY_ROOT}/projects/smoke-repo.toml" \
rm -rf "$MOCK_BIN" /tmp/smoke-test-repo \ "${FACTORY_ROOT}/docker-compose.yml"
"${FACTORY_ROOT}/projects/smoke-repo.toml"
# Restore .env only if we created the backup # Restore .env only if we created the backup
if [ -f "${FACTORY_ROOT}/.env.smoke-backup" ]; then if [ -f "${FACTORY_ROOT}/.env.smoke-backup" ]; then
mv "${FACTORY_ROOT}/.env.smoke-backup" "${FACTORY_ROOT}/.env" mv "${FACTORY_ROOT}/.env.smoke-backup" "${FACTORY_ROOT}/.env"
@ -41,11 +40,11 @@ trap cleanup EXIT
if [ -f "${FACTORY_ROOT}/.env" ]; then if [ -f "${FACTORY_ROOT}/.env" ]; then
cp "${FACTORY_ROOT}/.env" "${FACTORY_ROOT}/.env.smoke-backup" cp "${FACTORY_ROOT}/.env" "${FACTORY_ROOT}/.env.smoke-backup"
fi fi
# Start with a clean .env # Start with a clean .env (setup_forge writes tokens here)
printf '' > "${FACTORY_ROOT}/.env" printf '' > "${FACTORY_ROOT}/.env"
# ── 1. Verify mock Forgejo is ready ───────────────────────────────────────── # ── 1. Verify Forgejo is ready ──────────────────────────────────────────────
echo "=== 1/6 Verifying mock Forgejo at ${FORGE_URL} ===" echo "=== 1/6 Verifying Forgejo at ${FORGE_URL} ==="
retries=0 retries=0
api_version="" api_version=""
while true; do while true; do
@ -56,64 +55,163 @@ while true; do
fi fi
retries=$((retries + 1)) retries=$((retries + 1))
if [ "$retries" -gt 30 ]; then if [ "$retries" -gt 30 ]; then
fail "Mock Forgejo API not responding after 30s" fail "Forgejo API not responding after 30s"
exit 1 exit 1
fi fi
sleep 1 sleep 1
done done
pass "Mock Forgejo API v${api_version} (${retries}s)" pass "Forgejo API v${api_version} (${retries}s)"
# Verify bootstrap admin user exists
if curl -sf --max-time 5 "${FORGE_URL}/api/v1/users/${SETUP_ADMIN}" >/dev/null 2>&1; then
pass "Bootstrap admin '${SETUP_ADMIN}' exists"
else
fail "Bootstrap admin '${SETUP_ADMIN}' not found — was Forgejo set up?"
exit 1
fi
# ── 2. Set up mock binaries ───────────────────────────────────────────────── # ── 2. Set up mock binaries ─────────────────────────────────────────────────
echo "=== 2/6 Setting up mock binaries ===" echo "=== 2/6 Setting up mock binaries ==="
mkdir -p "$MOCK_BIN" mkdir -p "$MOCK_BIN" "$MOCK_STATE"
# Store bootstrap admin credentials for the docker mock
printf '%s:%s' "${SETUP_ADMIN}" "${SETUP_PASS}" > "$MOCK_STATE/bootstrap_creds"
# ── Mock: docker ── # ── Mock: docker ──
# Intercepts docker exec calls that disinto init --bare makes to Forgejo CLI # Routes 'docker exec' user-creation calls to the Forgejo admin API,
# using the bootstrap admin's credentials.
cat > "$MOCK_BIN/docker" << 'DOCKERMOCK' cat > "$MOCK_BIN/docker" << 'DOCKERMOCK'
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
FORGE_URL="${SMOKE_FORGE_URL:-${FORGE_URL:-http://localhost:3000}}"
if [ "${1:-}" = "ps" ]; then exit 0; fi FORGE_URL="${SMOKE_FORGE_URL:-http://localhost:3000}"
MOCK_STATE="/tmp/smoke-mock-state"
if [ ! -f "$MOCK_STATE/bootstrap_creds" ]; then
echo "mock-docker: bootstrap credentials not found" >&2
exit 1
fi
BOOTSTRAP_CREDS="$(cat "$MOCK_STATE/bootstrap_creds")"
# docker ps — return empty (no containers running)
if [ "${1:-}" = "ps" ]; then
exit 0
fi
# docker exec — route to Forgejo API
if [ "${1:-}" = "exec" ]; then if [ "${1:-}" = "exec" ]; then
shift shift # remove 'exec'
# Skip docker exec flags (-u VALUE, -T, -i, etc.)
while [ $# -gt 0 ] && [ "${1#-}" != "$1" ]; do while [ $# -gt 0 ] && [ "${1#-}" != "$1" ]; do
case "$1" in -u|-w|-e) shift 2 ;; *) shift ;; esac
done
shift # container name
if [ "${1:-}" = "forgejo" ] && [ "${2:-}" = "admin" ] && [ "${3:-}" = "user" ]; then
subcmd="${4:-}"
if [ "$subcmd" = "list" ]; then echo "ID Username Email"; exit 0; fi
if [ "$subcmd" = "create" ]; then
shift 4; username="" password="" email="" is_admin="false"
while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--admin) is_admin="true"; shift ;; --username) username="$2"; shift 2 ;; -u|-w|-e) shift 2 ;;
--password) password="$2"; shift 2 ;; --email) email="$2"; shift 2 ;; *) shift ;;
--must-change-password*) shift ;; *) shift ;;
esac esac
done done
curl -sf -X POST -H "Content-Type: application/json" \ shift # remove container name (e.g. disinto-forgejo)
"${FORGE_URL}/api/v1/admin/users" \
-d "{\"username\":\"${username}\",\"password\":\"${password}\",\"email\":\"${email}\",\"must_change_password\":false}" >/dev/null 2>&1 # $@ is now: forgejo admin user list|create [flags]
if [ "$is_admin" = "true" ]; then if [ "${1:-}" = "forgejo" ] && [ "${2:-}" = "admin" ] && [ "${3:-}" = "user" ]; then
curl -sf -X PATCH -H "Content-Type: application/json" \ subcmd="${4:-}"
"${FORGE_URL}/api/v1/admin/users/${username}" \
-d "{\"admin\":true,\"must_change_password\":false}" >/dev/null 2>&1 || true if [ "$subcmd" = "list" ]; then
echo "ID Username Email"
exit 0
fi fi
echo "New user '${username}' has been successfully created!"; exit 0
fi if [ "$subcmd" = "create" ]; then
if [ "$subcmd" = "change-password" ]; then shift 4 # skip 'forgejo admin user create'
shift 4; username="" username="" password="" email="" is_admin="false"
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in --username) username="$2"; shift 2 ;; --password) shift 2 ;; --must-change-password*|--config*) shift ;; *) shift ;; esac case "$1" in
--admin) is_admin="true"; shift ;;
--username) username="$2"; shift 2 ;;
--password) password="$2"; shift 2 ;;
--email) email="$2"; shift 2 ;;
--must-change-password*) shift ;;
*) shift ;;
esac
done done
curl -sf -X PATCH -H "Content-Type: application/json" \
if [ -z "$username" ] || [ -z "$password" ] || [ -z "$email" ]; then
echo "mock-docker: missing required args" >&2
exit 1
fi
# Create user via Forgejo admin API
if ! curl -sf -X POST \
-u "$BOOTSTRAP_CREDS" \
-H "Content-Type: application/json" \
"${FORGE_URL}/api/v1/admin/users" \
-d "{\"username\":\"${username}\",\"password\":\"${password}\",\"email\":\"${email}\",\"must_change_password\":false,\"login_name\":\"${username}\",\"source_id\":0}" \
>/dev/null 2>&1; then
echo "mock-docker: failed to create user '${username}'" >&2
exit 1
fi
# Patch user: ensure must_change_password is false (Forgejo admin
# API POST may ignore it) and promote to admin if requested
patch_body="{\"must_change_password\":false,\"login_name\":\"${username}\",\"source_id\":0"
if [ "$is_admin" = "true" ]; then
patch_body="${patch_body},\"admin\":true"
fi
patch_body="${patch_body}}"
curl -sf -X PATCH \
-u "$BOOTSTRAP_CREDS" \
-H "Content-Type: application/json" \
"${FORGE_URL}/api/v1/admin/users/${username}" \ "${FORGE_URL}/api/v1/admin/users/${username}" \
-d "{\"must_change_password\":false}" >/dev/null 2>&1 || true -d "${patch_body}" \
>/dev/null 2>&1 || true
echo "New user '${username}' has been successfully created!"
exit 0
fi
if [ "$subcmd" = "change-password" ]; then
shift 4 # skip 'forgejo admin user change-password'
username="" password=""
while [ $# -gt 0 ]; do
case "$1" in
--username) username="$2"; shift 2 ;;
--password) password="$2"; shift 2 ;;
--must-change-password*) shift ;;
--config*) shift ;;
*) shift ;;
esac
done
if [ -z "$username" ]; then
echo "mock-docker: change-password missing --username" >&2
exit 1
fi
# PATCH user via Forgejo admin API to clear must_change_password
patch_body="{\"must_change_password\":false,\"login_name\":\"${username}\",\"source_id\":0"
if [ -n "$password" ]; then
patch_body="${patch_body},\"password\":\"${password}\""
fi
patch_body="${patch_body}}"
if ! curl -sf -X PATCH \
-u "$BOOTSTRAP_CREDS" \
-H "Content-Type: application/json" \
"${FORGE_URL}/api/v1/admin/users/${username}" \
-d "${patch_body}" \
>/dev/null 2>&1; then
echo "mock-docker: failed to change-password for '${username}'" >&2
exit 1
fi
exit 0 exit 0
fi fi
fi fi
echo "mock-docker: unhandled exec: $*" >&2
exit 1
fi fi
echo "mock-docker: unhandled command: $*" >&2
exit 1 exit 1
DOCKERMOCK DOCKERMOCK
chmod +x "$MOCK_BIN/docker" chmod +x "$MOCK_BIN/docker"
@ -133,8 +231,11 @@ chmod +x "$MOCK_BIN/claude"
printf '#!/usr/bin/env bash\nexit 0\n' > "$MOCK_BIN/tmux" printf '#!/usr/bin/env bash\nexit 0\n' > "$MOCK_BIN/tmux"
chmod +x "$MOCK_BIN/tmux" chmod +x "$MOCK_BIN/tmux"
# No crontab mock — use real BusyBox crontab (available in the Forgejo
# Alpine image). Cron entries are verified via 'crontab -l' in step 6.
export PATH="$MOCK_BIN:$PATH" export PATH="$MOCK_BIN:$PATH"
pass "Mock binaries installed" pass "Mock binaries installed (docker, claude, tmux)"
# ── 3. Run disinto init ───────────────────────────────────────────────────── # ── 3. Run disinto init ─────────────────────────────────────────────────────
echo "=== 3/6 Running disinto init ===" echo "=== 3/6 Running disinto init ==="
@ -144,26 +245,9 @@ rm -f "${FACTORY_ROOT}/projects/smoke-repo.toml"
git config --global user.email "smoke@test.local" git config --global user.email "smoke@test.local"
git config --global user.name "Smoke Test" git config --global user.name "Smoke Test"
# USER needs to be set twice: assignment then export (SC2155)
USER=$(whoami)
export USER
# Create mock git repo to avoid clone failure (mock server has no git support)
mkdir -p "/tmp/smoke-test-repo"
cd "/tmp/smoke-test-repo"
git init --quiet
git config user.email "smoke@test.local"
git config user.name "Smoke Test"
echo "# smoke-repo" > README.md
git add README.md
git commit --quiet -m "Initial commit"
export SMOKE_FORGE_URL="$FORGE_URL" export SMOKE_FORGE_URL="$FORGE_URL"
export FORGE_URL export FORGE_URL
# Skip push to mock server (no git support)
export SKIP_PUSH=true
if bash "${FACTORY_ROOT}/bin/disinto" init \ if bash "${FACTORY_ROOT}/bin/disinto" init \
"${TEST_SLUG}" \ "${TEST_SLUG}" \
--bare --yes \ --bare --yes \
@ -206,6 +290,35 @@ if [ "$repo_found" = false ]; then
fail "Repo not found on Forgejo under any expected path" fail "Repo not found on Forgejo under any expected path"
fi fi
# Labels exist on repo — use bootstrap admin to check
setup_token=$(curl -sf -X POST \
-u "${SETUP_ADMIN}:${SETUP_PASS}" \
-H "Content-Type: application/json" \
"${FORGE_URL}/api/v1/users/${SETUP_ADMIN}/tokens" \
-d '{"name":"smoke-verify","scopes":["all"]}' 2>/dev/null \
| jq -r '.sha1 // empty') || setup_token=""
if [ -n "$setup_token" ]; then
label_count=0
for repo_path in "${TEST_SLUG}" "dev-bot/smoke-repo" "disinto-admin/smoke-repo"; do
label_count=$(curl -sf \
-H "Authorization: token ${setup_token}" \
"${FORGE_URL}/api/v1/repos/${repo_path}/labels?limit=50" 2>/dev/null \
| jq 'length' 2>/dev/null) || label_count=0
if [ "$label_count" -gt 0 ]; then
break
fi
done
if [ "$label_count" -ge 5 ]; then
pass "Labels created on repo (${label_count} labels)"
else
fail "Expected >= 5 labels, found ${label_count}"
fi
else
fail "Could not obtain verification token from bootstrap admin"
fi
# ── 5. Verify local state ─────────────────────────────────────────────────── # ── 5. Verify local state ───────────────────────────────────────────────────
echo "=== 5/6 Verifying local state ===" echo "=== 5/6 Verifying local state ==="
@ -244,7 +357,7 @@ else
fail ".env not found" fail ".env not found"
fi fi
# Repo was cloned (mock git repo created before disinto init) # Repo was cloned
if [ -d "/tmp/smoke-test-repo/.git" ]; then if [ -d "/tmp/smoke-test-repo/.git" ]; then
pass "Repo cloned to /tmp/smoke-test-repo" pass "Repo cloned to /tmp/smoke-test-repo"
else else

0
vault/.locks/.gitkeep Normal file
View file

45
vault/AGENTS.md Normal file
View file

@ -0,0 +1,45 @@
<!-- last-reviewed: f32707ba659de278a3af434e3549fb8a8dce9d3a -->
# Vault Agent
**Role**: Three-pipeline gate — action safety classification, resource procurement, and human-action drafting.
**Pipeline A — Action Gating (*.json)**: Actions enter a pending queue and are
classified by Claude via `vault-agent.sh`, which can auto-approve (call
`vault-fire.sh` directly), auto-reject (call `vault-reject.sh`), or escalate
to a human by writing `PHASE:escalate` to a phase file — using the same
unified escalation path as dev/action agents.
**Pipeline B — Procurement (*.md)**: The planner files resource requests as
markdown files in `$OPS_REPO_ROOT/vault/pending/`. `vault-poll.sh` notifies the human via
vault/forge. The human fulfills the request (creates accounts, provisions infra,
adds secrets to `.env`) and moves the file to `$OPS_REPO_ROOT/vault/approved/`.
`vault-fire.sh` then extracts the proposed entry and appends it to
`$OPS_REPO_ROOT/RESOURCES.md`.
**Pipeline C — Rent-a-Human (outreach drafts)**: Any agent can dispatch the
`run-rent-a-human` formula (via an `action` issue) when a task requires a human
touch — posting on Reddit, commenting on HN, signing up for a service, etc.
Claude drafts copy-paste-ready content to `vault/outreach/{platform}/drafts/`
and notifies the human via vault/forge for one-click execution. No vault approval
needed — the human reviews and publishes directly.
**Trigger**: `vault-poll.sh` runs every 30 min via cron.
**Key files**:
- `vault/vault-poll.sh` — Processes pending items: retry approved, auto-reject after 48h timeout, invoke vault-agent for JSON actions, notify human for procurement requests
- `vault/vault-agent.sh` — Classifies and routes pending JSON actions via `claude -p`: auto-approve, auto-reject, or escalate to human
- `vault/vault-env.sh` — Shared env setup for vault sub-scripts: sources `lib/env.sh`, overrides `FORGE_TOKEN` with `FORGE_VAULT_TOKEN`, sets `VAULT_TOKEN` for vault-runner container
- `vault/PROMPT.md` — System prompt for the vault agent's Claude invocation
- `vault/vault-fire.sh` — Executes an approved action (JSON) in an **ephemeral Docker container** with vault-only secrets injected (GITHUB_TOKEN, CLAWHUB_TOKEN — never exposed to agents). For deployment actions, calls `lib/ci-helpers.sh:ci_promote()` to gate production promotes via Woodpecker environments. Writes `$OPS_REPO_ROOT/RESOURCES.md` entry for procurement MD approvals.
- `vault/vault-reject.sh` — Marks a JSON action as rejected
- `formulas/run-rent-a-human.toml` — Formula for human-action drafts: Claude researches target platform norms, drafts copy-paste content, writes to `vault/outreach/{platform}/drafts/`, notifies human via vault/forge
**Procurement flow** (all vault items live in `$OPS_REPO_ROOT/vault/`):
1. Planner drops `$OPS_REPO_ROOT/vault/pending/<name>.md` with what/why/proposed RESOURCES.md entry
2. `vault-poll.sh` notifies human via vault/forge
3. Human fulfills: creates account, adds secrets to `.env`, moves file to `approved/`
4. `vault-fire.sh` extracts proposed entry, appends to `$OPS_REPO_ROOT/RESOURCES.md`, moves to `fired/`
5. Next planner run reads RESOURCES.md → new capability available → unblocks prerequisite tree
**Environment variables consumed**:
- All from `lib/env.sh`

122
vault/PROMPT.md Normal file
View file

@ -0,0 +1,122 @@
# Vault Agent
You are the vault agent for `$FORGE_REPO`. You were called by
`vault-poll.sh` because one or more actions in `$OPS_REPO_ROOT/vault/pending/` need
classification and routing.
## Two Pipelines
The vault handles two kinds of items:
### A. Action Gating (*.json)
Actions from agents that need safety classification before execution.
You classify and route these: auto-approve, escalate, or reject.
### B. Procurement Requests (*.md)
Resource requests from the planner. These always escalate to the human —
you do NOT auto-approve or reject procurement requests. The human fulfills
the request (creates accounts, provisions infra, adds secrets to .env)
and moves the file from `$OPS_REPO_ROOT/vault/pending/` to `$OPS_REPO_ROOT/vault/approved/`.
`vault-fire.sh` then writes the RESOURCES.md entry.
## Your Job (Action Gating only)
For each pending JSON action, decide: **auto-approve**, **escalate**, or **reject**.
## Routing Table (risk × reversibility)
| Risk | Reversible | Route |
|----------|------------|---------------------------------------------|
| low | true | auto-approve → fire immediately |
| low | false | auto-approve → fire, log prominently |
| medium | true | auto-approve → fire, notify via vault/forge |
| medium | false | escalate via vault/forge → wait for human reply |
| high | any | always escalate → wait for human reply |
## Rules
1. **Never lower risk.** You may override the source agent's self-assessed
risk *upward*, never downward. If a `blog-post` looks like it contains
pricing claims, bump it to `medium` or `high`.
2. **`requires_human: true` always escalates.** Regardless of risk level.
3. **Unknown action types → reject** with reason `unknown_type`.
4. **Malformed JSON → reject** with reason `malformed`.
5. **Payload validation:** Check that the payload has the minimum required
fields for the action type. Missing fields → reject with reason.
6. **Procurement requests (*.md) → skip.** These are handled by the human
directly. Do not attempt to classify, approve, or reject them.
## Action Type Defaults
| Type | Default Risk | Default Reversible |
|------------------|-------------|-------------------|
| `blog-post` | low | yes |
| `social-post` | medium | yes |
| `email-blast` | high | no |
| `pricing-change` | high | partial |
| `dns-change` | high | partial |
| `webhook-call` | medium | depends |
| `stripe-charge` | high | no |
## Procurement Request Format (reference only)
Procurement requests dropped by the planner look like:
```markdown
# Procurement Request: <name>
## What
<description of what's needed>
## Why
<why the factory needs this>
## Unblocks
<which prerequisite tree objective(s) this unblocks>
## Proposed RESOURCES.md Entry
## <resource-id>
- type: <type>
- capability: <capabilities>
- env: <env var names if applicable>
```
## Available Tools
You have shell access. Use these for routing decisions:
```bash
source ${FACTORY_ROOT}/lib/env.sh
```
### Auto-approve and fire
```bash
bash ${FACTORY_ROOT}/vault/vault-fire.sh <action-id>
```
### Escalate
```bash
echo "PHASE:escalate" > "$PHASE_FILE"
```
### Reject
```bash
bash ${FACTORY_ROOT}/vault/vault-reject.sh <action-id> "<reason>"
```
## Output Format
After processing each action, print exactly:
```
ROUTE: <action-id><auto-approve|escalate|reject><reason>
```
## Important
- Process ALL pending JSON actions in the batch. Never skip silently.
- For auto-approved actions, fire them immediately via `vault-fire.sh`.
- For escalated actions, move to `$OPS_REPO_ROOT/vault/approved/` only AFTER human approval.
- Read the action JSON carefully. Check the payload, not just the metadata.
- Ignore `.md` files in pending/ — those are procurement requests handled
separately by vault-poll.sh and the human.

View file

@ -1,81 +0,0 @@
# Vault Action TOML Schema
This document defines the schema for vault action TOML files used in the PR-based approval workflow (issue #74).
## File Location
Vault actions are stored in `vault/actions/<action-id>.toml` on the ops repo.
## Schema Definition
```toml
# Required
id = "publish-skill-20260331"
formula = "clawhub-publish"
context = "SKILL.md bumped to 0.3.0"
# Required secrets to inject
secrets = ["CLAWHUB_TOKEN"]
# Optional
model = "sonnet"
tools = ["clawhub"]
timeout_minutes = 30
```
## Field Specifications
### Required Fields
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Unique identifier for the vault action. Format: `<action-type>-<date>` (e.g., `publish-skill-20260331`) |
| `formula` | string | Formula name from `formulas/` directory that defines the operational task to execute |
| `context` | string | Human-readable explanation of why this action is needed. Used in PR description |
| `secrets` | array of strings | List of secret names to inject into the execution environment. Only these secrets are passed to the container |
### Optional Fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `model` | string | `sonnet` | Override the default Claude model for this action |
| `tools` | array of strings | `[]` | MCP tools to enable during execution |
| `timeout_minutes` | integer | `60` | Maximum execution time in minutes |
## Secret Names
Secret names must be defined in `.env.vault.enc` on the ops repo. The vault validates that requested secrets exist in the allowlist before execution.
Common secret names:
- `CLAWHUB_TOKEN` - Token for ClawHub skill publishing
- `GITHUB_TOKEN` - GitHub API token for repository operations
- `DEPLOY_KEY` - Infrastructure deployment key
## Validation Rules
1. **Required fields**: `id`, `formula`, `context`, and `secrets` must be present
2. **Formula validation**: The formula must exist in the `formulas/` directory
3. **Secret validation**: All secrets in the `secrets` array must be in the allowlist
4. **No unknown fields**: The TOML must not contain fields outside the schema
5. **ID uniqueness**: The `id` must be unique across all vault actions
## Example Files
See `vault/examples/` for complete examples:
- `webhook-call.toml` - Example of calling an external webhook
- `promote.toml` - Example of promoting a build/artifact
- `publish.toml` - Example of publishing a skill to ClawHub
## Usage
Validate a vault action file:
```bash
./vault/validate.sh vault/actions/<action-id>.toml
```
The validator will check:
- All required fields are present
- Secret names are in the allowlist
- No unknown fields are present
- Formula exists in the formulas directory

View file

@ -1,21 +0,0 @@
# vault/examples/promote.toml
# Example: Promote a build/artifact to production
#
# This vault action demonstrates promoting a built artifact to a
# production environment with proper authentication.
id = "promote-20260331"
formula = "run-supervisor"
context = "Promote build v1.2.3 to production environment"
# Secrets to inject for deployment authentication
secrets = ["DEPLOY_KEY", "DOCKER_HUB_TOKEN"]
# Optional: use larger model for complex deployment logic
model = "sonnet"
# Optional: enable MCP tools for container operations
tools = ["docker"]
# Optional: deployments may take longer
timeout_minutes = 45

View file

@ -1,21 +0,0 @@
# vault/examples/publish.toml
# Example: Publish a skill to ClawHub
#
# This vault action demonstrates publishing a skill to ClawHub
# using the clawhub-publish formula.
id = "publish-site-20260331"
formula = "run-publish-site"
context = "Publish updated site to production"
# Secrets to inject (only these get passed to the container)
secrets = ["DEPLOY_KEY"]
# Optional: use sonnet model
model = "sonnet"
# Optional: enable MCP tools
tools = []
# Optional: 30 minute timeout
timeout_minutes = 30

View file

@ -1,35 +0,0 @@
# vault/examples/release.toml
# Example: Release vault item schema
#
# This example demonstrates the release vault item schema for creating
# versioned releases with vault-gated approval.
#
# The release formula tags Forgejo main, pushes to mirrors, builds and
# tags the agents Docker image, and restarts agent containers.
#
# Example vault item (auto-generated by `disinto release v1.2.0`):
#
# id = "release-v120"
# formula = "release"
# context = "Release v1.2.0"
# secrets = []
#
# Steps executed by the release formula:
# 1. preflight - Validate prerequisites (version, FORGE_TOKEN, Docker)
# 2. tag-main - Create tag on Forgejo main via API
# 3. push-mirrors - Push tag to Codeberg and GitHub mirrors
# 4. build-image - Build agents Docker image with --no-cache
# 5. tag-image - Tag image with version (disinto-agents:v1.2.0)
# 6. restart-agents - Restart agent containers with new image
# 7. commit-result - Write release result to tracking file
id = "release-v120"
formula = "release"
context = "Release v1.2.0 — includes vault redesign, .profile system, architect agent"
secrets = []
# Optional: specify a larger model for complex release logic
# model = "sonnet"
# Optional: releases may take longer due to Docker builds
# timeout_minutes = 60

View file

@ -1,21 +0,0 @@
# vault/examples/webhook-call.toml
# Example: Call an external webhook with authentication
#
# This vault action demonstrates calling an external webhook endpoint
# with proper authentication via injected secrets.
id = "webhook-call-20260331"
formula = "run-rent-a-human"
context = "Notify Slack channel about deployment completion"
# Secrets to inject (only these get passed to the container)
secrets = ["DEPLOY_KEY"]
# Optional: use sonnet model for this action
model = "sonnet"
# Optional: enable MCP tools
tools = []
# Optional: 30 minute timeout
timeout_minutes = 30

View file

@ -1,46 +0,0 @@
#!/usr/bin/env bash
# vault/validate.sh — Validate vault action TOML files
#
# Usage: ./vault/validate.sh <path-to-toml>
#
# Validates a vault action TOML file according to the schema defined in
# vault/SCHEMA.md. Checks:
# - Required fields are present
# - Secret names are in the allowlist
# - No unknown fields are present
# - Formula exists in formulas/
set -euo pipefail
# Get script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Source vault environment
source "$SCRIPT_DIR/vault-env.sh"
# Get the TOML file to validate
TOML_FILE="${1:-}"
if [ -z "$TOML_FILE" ]; then
echo "Usage: $0 <path-to-toml>" >&2
echo "Example: $0 vault/examples/publish.toml" >&2
exit 1
fi
# Resolve relative paths
if [[ "$TOML_FILE" != /* ]]; then
TOML_FILE="$(cd "$(dirname "$TOML_FILE")" && pwd)/$(basename "$TOML_FILE")"
fi
# Run validation
if validate_vault_action "$TOML_FILE"; then
echo "VALID: $TOML_FILE"
echo " ID: $VAULT_ACTION_ID"
echo " Formula: $VAULT_ACTION_FORMULA"
echo " Context: $VAULT_ACTION_CONTEXT"
echo " Secrets: $VAULT_ACTION_SECRETS"
exit 0
else
echo "INVALID: $TOML_FILE" >&2
exit 1
fi

97
vault/vault-agent.sh Executable file
View file

@ -0,0 +1,97 @@
#!/usr/bin/env bash
# vault-agent.sh — Invoke claude -p to classify and route pending vault actions
#
# Called by vault-poll.sh when pending actions exist. Reads all pending/*.json,
# builds a prompt with action summaries, and lets the LLM decide routing.
#
# The LLM can call vault-fire.sh (auto-approve) or vault-reject.sh (reject)
# directly. For escalations, it writes a PHASE:escalate file and marks the
# action as "escalated" in pending/ so vault-poll skips it on future runs.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/vault-env.sh"
VAULT_SCRIPT_DIR="${FACTORY_ROOT}/vault"
OPS_VAULT_DIR="${OPS_REPO_ROOT}/vault"
PROMPT_FILE="${VAULT_SCRIPT_DIR}/PROMPT.md"
LOGFILE="${VAULT_SCRIPT_DIR}/vault.log"
CLAUDE_TIMEOUT="${CLAUDE_TIMEOUT:-3600}"
log() {
printf '[%s] vault-agent: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
}
# Collect all pending actions (skip already-escalated)
ACTIONS_BATCH=""
ACTION_COUNT=0
for action_file in "${OPS_VAULT_DIR}/pending/"*.json; do
[ -f "$action_file" ] || continue
ACTION_STATUS=$(jq -r '.status // ""' < "$action_file" 2>/dev/null)
[ "$ACTION_STATUS" = "escalated" ] && continue
# Validate JSON
if ! jq empty < "$action_file" 2>/dev/null; then
ACTION_ID=$(basename "$action_file" .json)
log "malformed JSON: $action_file — rejecting"
bash "${VAULT_SCRIPT_DIR}/vault-reject.sh" "$ACTION_ID" "malformed JSON" 2>/dev/null || true
continue
fi
ACTION_JSON=$(cat "$action_file")
ACTIONS_BATCH="${ACTIONS_BATCH}
--- ACTION ---
$(echo "$ACTION_JSON" | jq '.')
--- END ACTION ---
"
ACTION_COUNT=$((ACTION_COUNT + 1))
done
if [ "$ACTION_COUNT" -eq 0 ]; then
log "no actionable pending items"
exit 0
fi
log "processing $ACTION_COUNT pending action(s) via claude -p"
# Build the prompt
SYSTEM_PROMPT=$(cat "$PROMPT_FILE" 2>/dev/null || echo "You are a vault agent. Classify and route actions.")
PROMPT="${SYSTEM_PROMPT}
## Pending Actions (${ACTION_COUNT} total)
${ACTIONS_BATCH}
## Environment
- FACTORY_ROOT=${FACTORY_ROOT}
- OPS_REPO_ROOT=${OPS_REPO_ROOT}
- Vault data: ${OPS_VAULT_DIR}
- vault-fire.sh: bash ${VAULT_SCRIPT_DIR}/vault-fire.sh <action-id>
- vault-reject.sh: bash ${VAULT_SCRIPT_DIR}/vault-reject.sh <action-id> \"<reason>\"
Process each action now. For auto-approve, fire immediately. For reject, call vault-reject.sh.
For actions that need human approval (escalate), write a PHASE:escalate file
to signal the unified escalation path:
printf 'PHASE:escalate\nReason: vault procurement — %s\n' '<action summary>' \\
> /tmp/vault-escalate-<action-id>.phase
Then STOP and wait — a human will review via the forge."
CLAUDE_OUTPUT=$(timeout "$CLAUDE_TIMEOUT" claude -p "$PROMPT" \
--model sonnet \
--dangerously-skip-permissions \
--max-turns 20 \
2>/dev/null) || true
log "claude finished ($(echo "$CLAUDE_OUTPUT" | wc -c) bytes)"
# Log routing decisions
ROUTES=$(echo "$CLAUDE_OUTPUT" | grep "^ROUTE:" || true)
if [ -n "$ROUTES" ]; then
echo "$ROUTES" | while read -r line; do
log " $line"
done
fi

View file

@ -7,148 +7,3 @@
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib/env.sh" source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib/env.sh"
# Use vault-bot's own Forgejo identity # Use vault-bot's own Forgejo identity
FORGE_TOKEN="${FORGE_VAULT_TOKEN:-${FORGE_TOKEN}}" FORGE_TOKEN="${FORGE_VAULT_TOKEN:-${FORGE_TOKEN}}"
# Vault redesign in progress (PR-based approval workflow)
# This file is kept for shared env setup; scripts being replaced by #73
# =============================================================================
# VAULT ACTION VALIDATION
# =============================================================================
# Allowed secret names - must match keys in .env.vault.enc
VAULT_ALLOWED_SECRETS="CLAWHUB_TOKEN GITHUB_TOKEN DEPLOY_KEY NPM_TOKEN DOCKER_HUB_TOKEN"
# Validate a vault action TOML file
# Usage: validate_vault_action <path-to-toml>
# Returns: 0 if valid, 1 if invalid
# Sets: VAULT_ACTION_ID, VAULT_ACTION_FORMULA, VAULT_ACTION_CONTEXT on success
validate_vault_action() {
local toml_file="$1"
if [ -z "$toml_file" ]; then
echo "ERROR: No TOML file specified" >&2
return 1
fi
if [ ! -f "$toml_file" ]; then
echo "ERROR: File not found: $toml_file" >&2
return 1
fi
log "Validating vault action: $toml_file"
# Get script directory for relative path resolution
# FACTORY_ROOT is set by lib/env.sh which is sourced above
local formulas_dir="${FACTORY_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}/formulas"
# Extract TOML values using grep/sed (basic TOML parsing)
local toml_content
toml_content=$(cat "$toml_file")
# Extract string values (id, formula, context)
local id formula context
id=$(echo "$toml_content" | grep -E '^id\s*=' | sed -E 's/^id\s*=\s*"(.*)"/\1/' | tr -d '\r')
formula=$(echo "$toml_content" | grep -E '^formula\s*=' | sed -E 's/^formula\s*=\s*"(.*)"/\1/' | tr -d '\r')
context=$(echo "$toml_content" | grep -E '^context\s*=' | sed -E 's/^context\s*=\s*"(.*)"/\1/' | tr -d '\r')
# Extract secrets array
local secrets_line secrets_array
secrets_line=$(echo "$toml_content" | grep -E '^secrets\s*=' | tr -d '\r')
secrets_array=$(echo "$secrets_line" | sed -E 's/^secrets\s*=\s*\[(.*)\]/\1/' | tr -d '[]"' | tr ',' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
# Check for unknown fields (any top-level key not in allowed list)
local unknown_fields
unknown_fields=$(echo "$toml_content" | grep -E '^[a-zA-Z_][a-zA-Z0-9_]*\s*=' | sed -E 's/^([a-zA-Z_][a-zA-Z0-9_]*)\s*=.*/\1/' | sort -u | while read -r field; do
case "$field" in
id|formula|context|secrets|model|tools|timeout_minutes) ;;
*) echo "$field" ;;
esac
done)
if [ -n "$unknown_fields" ]; then
echo "ERROR: Unknown fields in TOML: $(echo "$unknown_fields" | tr '\n' ', ' | sed 's/,$//')" >&2
return 1
fi
# Validate required fields
if [ -z "$id" ]; then
echo "ERROR: Missing required field: id" >&2
return 1
fi
if [ -z "$formula" ]; then
echo "ERROR: Missing required field: formula" >&2
return 1
fi
if [ -z "$context" ]; then
echo "ERROR: Missing required field: context" >&2
return 1
fi
# Validate formula exists in formulas/
if [ ! -f "$formulas_dir/${formula}.toml" ]; then
echo "ERROR: Formula not found: $formula" >&2
return 1
fi
# Validate secrets field exists and is not empty
if [ -z "$secrets_line" ]; then
echo "ERROR: Missing required field: secrets" >&2
return 1
fi
# Validate each secret is in the allowlist
for secret in $secrets_array; do
secret=$(echo "$secret" | tr -d '"' | xargs) # trim whitespace and quotes
if [ -n "$secret" ]; then
if ! echo " $VAULT_ALLOWED_SECRETS " | grep -q " $secret "; then
echo "ERROR: Unknown secret (not in allowlist): $secret" >&2
return 1
fi
fi
done
# Validate optional fields if present
# model
if echo "$toml_content" | grep -qE '^model\s*='; then
local model_value
model_value=$(echo "$toml_content" | grep -E '^model\s*=' | sed -E 's/^model\s*=\s*"(.*)"/\1/' | tr -d '\r')
if [ -z "$model_value" ]; then
echo "ERROR: 'model' must be a non-empty string" >&2
return 1
fi
fi
# tools
if echo "$toml_content" | grep -qE '^tools\s*='; then
local tools_line
tools_line=$(echo "$toml_content" | grep -E '^tools\s*=' | tr -d '\r')
if ! echo "$tools_line" | grep -q '\['; then
echo "ERROR: 'tools' must be an array" >&2
return 1
fi
fi
# timeout_minutes
if echo "$toml_content" | grep -qE '^timeout_minutes\s*='; then
local timeout_value
timeout_value=$(echo "$toml_content" | grep -E '^timeout_minutes\s*=' | sed -E 's/^timeout_minutes\s*=\s*([0-9]+)/\1/' | tr -d '\r')
if [ -z "$timeout_value" ] || [ "$timeout_value" -le 0 ] 2>/dev/null; then
echo "ERROR: 'timeout_minutes' must be a positive integer" >&2
return 1
fi
fi
# Export validated values (for use by caller script)
export VAULT_ACTION_ID="$id"
export VAULT_ACTION_FORMULA="$formula"
export VAULT_ACTION_CONTEXT="$context"
export VAULT_ACTION_SECRETS="$secrets_array"
log "VAULT_ACTION_ID=$VAULT_ACTION_ID"
log "VAULT_ACTION_FORMULA=$VAULT_ACTION_FORMULA"
log "VAULT_ACTION_SECRETS=$VAULT_ACTION_SECRETS"
return 0
}

141
vault/vault-fire.sh Executable file
View file

@ -0,0 +1,141 @@
#!/usr/bin/env bash
# vault-fire.sh — Execute an approved vault item by ID
#
# Handles two pipelines:
# A. Action gating (*.json): pending/ → approved/ → fired/
# Execution delegated to ephemeral vault-runner container via disinto vault-run.
# The vault-runner gets vault secrets (.env.vault.enc); this script does NOT.
# B. Procurement (*.md): approved/ → fired/ (writes RESOURCES.md entry)
#
# If item is in pending/, moves to approved/ first.
# If item is already in approved/, fires directly (crash recovery).
#
# Usage: bash vault-fire.sh <item-id>
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/vault-env.sh"
OPS_VAULT_DIR="${OPS_REPO_ROOT}/vault"
LOCKS_DIR="${DISINTO_LOG_DIR}/vault/.locks"
LOGFILE="${DISINTO_LOG_DIR}/vault/vault.log"
RESOURCES_FILE="${OPS_REPO_ROOT}/RESOURCES.md"
log() {
printf '[%s] vault-fire: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
}
ACTION_ID="${1:?Usage: vault-fire.sh <item-id>}"
# =============================================================================
# Detect pipeline: procurement (.md) or action gating (.json)
# =============================================================================
IS_PROCUREMENT=false
ACTION_FILE=""
if [ -f "${OPS_VAULT_DIR}/approved/${ACTION_ID}.md" ]; then
IS_PROCUREMENT=true
ACTION_FILE="${OPS_VAULT_DIR}/approved/${ACTION_ID}.md"
elif [ -f "${OPS_VAULT_DIR}/pending/${ACTION_ID}.md" ]; then
IS_PROCUREMENT=true
mv "${OPS_VAULT_DIR}/pending/${ACTION_ID}.md" "${OPS_VAULT_DIR}/approved/${ACTION_ID}.md"
ACTION_FILE="${OPS_VAULT_DIR}/approved/${ACTION_ID}.md"
log "$ACTION_ID: pending → approved (procurement)"
elif [ -f "${OPS_VAULT_DIR}/approved/${ACTION_ID}.json" ]; then
ACTION_FILE="${OPS_VAULT_DIR}/approved/${ACTION_ID}.json"
elif [ -f "${OPS_VAULT_DIR}/pending/${ACTION_ID}.json" ]; then
mv "${OPS_VAULT_DIR}/pending/${ACTION_ID}.json" "${OPS_VAULT_DIR}/approved/${ACTION_ID}.json"
ACTION_FILE="${OPS_VAULT_DIR}/approved/${ACTION_ID}.json"
TMP=$(mktemp)
jq '.status = "approved"' "$ACTION_FILE" > "$TMP" && mv "$TMP" "$ACTION_FILE"
log "$ACTION_ID: pending → approved"
else
log "ERROR: item $ACTION_ID not found in pending/ or approved/"
exit 1
fi
# Acquire lock
mkdir -p "$LOCKS_DIR"
LOCKFILE="${LOCKS_DIR}/${ACTION_ID}.lock"
if [ -f "$LOCKFILE" ]; then
LOCK_PID=$(cat "$LOCKFILE" 2>/dev/null || true)
if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then
log "$ACTION_ID: already being fired by PID $LOCK_PID"
exit 0
fi
fi
echo $$ > "$LOCKFILE"
trap 'rm -f "$LOCKFILE"' EXIT
# =============================================================================
# Pipeline A: Procurement — extract RESOURCES.md entry and append
# =============================================================================
if [ "$IS_PROCUREMENT" = true ]; then
log "$ACTION_ID: firing procurement request"
# Extract the proposed RESOURCES.md entry from the markdown file.
# Everything after the "## Proposed RESOURCES.md Entry" heading to EOF.
# Uses awk because the entry itself contains ## headings (## <resource-id>).
ENTRY=""
ENTRY=$(awk '/^## Proposed RESOURCES\.md Entry/{found=1; next} found{print}' "$ACTION_FILE" 2>/dev/null || true)
# Strip leading/trailing blank lines and markdown code fences
ENTRY=$(echo "$ENTRY" | sed '/^```/d' | sed -e '/./,$!d' -e :a -e '/^\n*$/{$d;N;ba;}')
if [ -z "$ENTRY" ]; then
log "ERROR: $ACTION_ID has no '## Proposed RESOURCES.md Entry' section"
exit 1
fi
# Append entry to RESOURCES.md
printf '\n%s\n' "$ENTRY" >> "$RESOURCES_FILE"
log "$ACTION_ID: wrote RESOURCES.md entry"
# Move to fired/
mv "$ACTION_FILE" "${OPS_VAULT_DIR}/fired/${ACTION_ID}.md"
rm -f "${LOCKS_DIR}/${ACTION_ID}.notified"
log "$ACTION_ID: approved → fired (procurement)"
exit 0
fi
# =============================================================================
# Pipeline B: Action gating — delegate to ephemeral vault-runner container
# =============================================================================
ACTION_TYPE=$(jq -r '.type // ""' < "$ACTION_FILE")
ACTION_SOURCE=$(jq -r '.source // ""' < "$ACTION_FILE")
if [ -z "$ACTION_TYPE" ]; then
log "ERROR: $ACTION_ID has no type field"
exit 1
fi
log "$ACTION_ID: firing type=$ACTION_TYPE source=$ACTION_SOURCE via vault-runner"
FIRE_EXIT=0
# Delegate execution to the ephemeral vault-runner container.
# The vault-runner gets vault secrets (.env.vault.enc) injected at runtime;
# this host process never sees those secrets.
if [ -f "${FACTORY_ROOT}/.env.vault.enc" ] && [ -f "${FACTORY_ROOT}/docker-compose.yml" ]; then
bash "${FACTORY_ROOT}/bin/disinto" vault-run "$ACTION_ID" >> "$LOGFILE" 2>&1 || FIRE_EXIT=$?
else
# Fallback for bare-metal or pre-migration setups: run action handler directly
log "$ACTION_ID: no .env.vault.enc or docker-compose.yml — running action directly"
bash "${SCRIPT_DIR}/vault-run-action.sh" "$ACTION_ID" >> "$LOGFILE" 2>&1 || FIRE_EXIT=$?
fi
# =============================================================================
# Move to fired/ or leave in approved/ on failure
# =============================================================================
if [ "$FIRE_EXIT" -eq 0 ]; then
# Update with fired timestamp and move to fired/
TMP=$(mktemp)
jq --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '.status = "fired" | .fired_at = $ts' "$ACTION_FILE" > "$TMP" \
&& mv "$TMP" "${OPS_VAULT_DIR}/fired/${ACTION_ID}.json"
rm -f "$ACTION_FILE"
log "$ACTION_ID: approved → fired"
else
log "ERROR: $ACTION_ID fire failed (exit $FIRE_EXIT) — stays in approved/ for retry"
exit "$FIRE_EXIT"
fi

301
vault/vault-poll.sh Executable file
View file

@ -0,0 +1,301 @@
#!/usr/bin/env bash
# vault-poll.sh — Vault: process pending actions + procurement requests
#
# Runs every 30min via cron. Two pipelines:
# A. Action gating (*.json): auto-approve/escalate/reject via vault-agent.sh
# B. Procurement (*.md): notify human, fire approved requests via vault-fire.sh
#
# Phases:
# 1. Retry any approved/ items that weren't fired (crash recovery)
# 2. Auto-reject escalations with no reply for 48h
# 3. Invoke vault-agent.sh for new pending JSON actions
# 4. Notify human about new pending procurement requests (.md)
#
# Cron: */30 * * * * /path/to/disinto/vault/vault-poll.sh
#
# Peek: cat /tmp/vault-status
# Log: tail -f /path/to/disinto/vault/vault.log
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/../lib/env.sh"
# Use vault-bot's own Forgejo identity (#747)
FORGE_TOKEN="${FORGE_VAULT_TOKEN:-${FORGE_TOKEN}}"
LOGFILE="${DISINTO_LOG_DIR}/vault/vault.log"
STATUSFILE="/tmp/vault-status"
LOCKFILE="/tmp/vault-poll.lock"
VAULT_SCRIPT_DIR="${FACTORY_ROOT}/vault"
OPS_VAULT_DIR="${OPS_REPO_ROOT}/vault"
LOCKS_DIR="${DISINTO_LOG_DIR}/vault/.locks"
TIMEOUT_HOURS=48
# Prevent overlapping runs
if [ -f "$LOCKFILE" ]; then
LOCK_PID=$(cat "$LOCKFILE" 2>/dev/null)
if kill -0 "$LOCK_PID" 2>/dev/null; then
exit 0
fi
rm -f "$LOCKFILE"
fi
echo $$ > "$LOCKFILE"
trap 'rm -f "$LOCKFILE" "$STATUSFILE"' EXIT
log() {
printf '[%s] vault: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
}
status() {
printf '[%s] vault: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" > "$STATUSFILE"
log "$*"
}
# Acquire per-action lock (returns 0 if acquired, 1 if already locked)
lock_action() {
local action_id="$1"
local lockfile="${LOCKS_DIR}/${action_id}.lock"
mkdir -p "$LOCKS_DIR"
if [ -f "$lockfile" ]; then
local lock_pid
lock_pid=$(cat "$lockfile" 2>/dev/null || true)
if [ -n "$lock_pid" ] && kill -0 "$lock_pid" 2>/dev/null; then
return 1
fi
rm -f "$lockfile"
fi
echo $$ > "$lockfile"
return 0
}
unlock_action() {
local action_id="$1"
rm -f "${LOCKS_DIR}/${action_id}.lock"
}
# =============================================================================
# PHASE 1: Retry approved items (crash recovery — JSON actions + MD procurement)
# =============================================================================
status "phase 1: retrying approved items"
for action_file in "${OPS_VAULT_DIR}/approved/"*.json; do
[ -f "$action_file" ] || continue
ACTION_ID=$(jq -r '.id // ""' < "$action_file" 2>/dev/null)
[ -z "$ACTION_ID" ] && continue
if ! lock_action "$ACTION_ID"; then
log "skip $ACTION_ID — locked by another process"
continue
fi
log "retrying approved action: $ACTION_ID"
if bash "${VAULT_SCRIPT_DIR}/vault-fire.sh" "$ACTION_ID" >> "$LOGFILE" 2>&1; then
log "fired $ACTION_ID (retry)"
else
log "ERROR: fire failed for $ACTION_ID (retry)"
fi
unlock_action "$ACTION_ID"
done
# Retry approved procurement requests (.md)
for req_file in "${OPS_VAULT_DIR}/approved/"*.md; do
[ -f "$req_file" ] || continue
REQ_ID=$(basename "$req_file" .md)
if ! lock_action "$REQ_ID"; then
log "skip procurement $REQ_ID — locked by another process"
continue
fi
log "retrying approved procurement: $REQ_ID"
if bash "${VAULT_SCRIPT_DIR}/vault-fire.sh" "$REQ_ID" >> "$LOGFILE" 2>&1; then
log "fired procurement $REQ_ID (retry)"
else
log "ERROR: fire failed for procurement $REQ_ID (retry)"
fi
unlock_action "$REQ_ID"
done
# =============================================================================
# PHASE 2: Timeout escalations (48h no reply → auto-reject)
# =============================================================================
status "phase 2: checking escalation timeouts"
NOW_EPOCH=$(date +%s)
TIMEOUT_SECS=$((TIMEOUT_HOURS * 3600))
for action_file in "${OPS_VAULT_DIR}/pending/"*.json; do
[ -f "$action_file" ] || continue
ACTION_STATUS=$(jq -r '.status // ""' < "$action_file" 2>/dev/null)
[ "$ACTION_STATUS" != "escalated" ] && continue
ACTION_ID=$(jq -r '.id // ""' < "$action_file" 2>/dev/null)
ESCALATED_AT=$(jq -r '.escalated_at // ""' < "$action_file" 2>/dev/null)
[ -z "$ESCALATED_AT" ] && continue
ESCALATED_EPOCH=$(date -d "$ESCALATED_AT" +%s 2>/dev/null || echo 0)
AGE_SECS=$((NOW_EPOCH - ESCALATED_EPOCH))
if [ "$AGE_SECS" -gt "$TIMEOUT_SECS" ]; then
AGE_HOURS=$((AGE_SECS / 3600))
log "timeout: $ACTION_ID escalated ${AGE_HOURS}h ago with no reply — auto-rejecting"
bash "${VAULT_SCRIPT_DIR}/vault-reject.sh" "$ACTION_ID" "timeout (${AGE_HOURS}h, no human reply)" >> "$LOGFILE" 2>&1 || true
fi
done
# =============================================================================
# PHASE 3: Process new pending actions (JSON — action gating)
# =============================================================================
status "phase 3: processing pending actions"
PENDING_COUNT=0
PENDING_SUMMARY=""
for action_file in "${OPS_VAULT_DIR}/pending/"*.json; do
[ -f "$action_file" ] || continue
ACTION_STATUS=$(jq -r '.status // ""' < "$action_file" 2>/dev/null)
# Skip already-escalated actions (waiting for human reply)
[ "$ACTION_STATUS" = "escalated" ] && continue
ACTION_ID=$(jq -r '.id // ""' < "$action_file" 2>/dev/null)
[ -z "$ACTION_ID" ] && continue
if ! lock_action "$ACTION_ID"; then
log "skip $ACTION_ID — locked"
continue
fi
PENDING_COUNT=$((PENDING_COUNT + 1))
ACTION_TYPE=$(jq -r '.type // "unknown"' < "$action_file" 2>/dev/null)
ACTION_SOURCE=$(jq -r '.source // "unknown"' < "$action_file" 2>/dev/null)
PENDING_SUMMARY="${PENDING_SUMMARY} ${ACTION_ID} [${ACTION_TYPE}] from ${ACTION_SOURCE}\n"
unlock_action "$ACTION_ID"
done
if [ "$PENDING_COUNT" -gt 0 ]; then
log "found $PENDING_COUNT pending action(s), invoking vault-agent"
status "invoking vault-agent for $PENDING_COUNT action(s)"
bash "${VAULT_SCRIPT_DIR}/vault-agent.sh" >> "$LOGFILE" 2>&1 || {
log "ERROR: vault-agent failed"
}
fi
# =============================================================================
# PHASE 4: Notify human about new pending procurement requests (.md)
# =============================================================================
status "phase 4: processing pending procurement requests"
PROCURE_COUNT=0
for req_file in "${OPS_VAULT_DIR}/pending/"*.md; do
[ -f "$req_file" ] || continue
REQ_ID=$(basename "$req_file" .md)
# Check if already notified (marker file)
if [ -f "${LOCKS_DIR}/${REQ_ID}.notified" ]; then
continue
fi
if ! lock_action "$REQ_ID"; then
log "skip procurement $REQ_ID — locked"
continue
fi
PROCURE_COUNT=$((PROCURE_COUNT + 1))
# Extract title from first heading
REQ_TITLE=$(grep -m1 '^# ' "$req_file" | sed 's/^# //' || echo "$REQ_ID")
log "new procurement request: $REQ_ID$REQ_TITLE"
# Mark as notified so we don't re-send
mkdir -p "${LOCKS_DIR}"
touch "${LOCKS_DIR}/${REQ_ID}.notified"
unlock_action "$REQ_ID"
done
# =============================================================================
# PHASE 5: Detect vault-bot authorized comments on issues
# =============================================================================
status "phase 5: scanning for vault-bot authorized comments"
COMMENT_COUNT=0
if [ -n "${FORGE_REPO:-}" ] && [ -n "${FORGE_TOKEN:-}" ]; then
# Get open issues with action label
ACTION_ISSUES=$(curl -sf \
-H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_URL}/api/v1/repos/${FORGE_REPO}/issues?state=open&labels=action&limit=50" 2>/dev/null) || ACTION_ISSUES="[]"
ISSUE_COUNT=$(printf '%s' "$ACTION_ISSUES" | jq 'length')
for idx in $(seq 0 $((ISSUE_COUNT - 1))); do
ISSUE_NUM=$(printf '%s' "$ACTION_ISSUES" | jq -r ".[$idx].number")
# Skip if already processed
if [ -f "${LOCKS_DIR}/issue-${ISSUE_NUM}.vault-fired" ]; then
continue
fi
# Get comments on this issue
COMMENTS=$(curl -sf \
-H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_URL}/api/v1/repos/${FORGE_REPO}/issues/${ISSUE_NUM}/comments?limit=50" 2>/dev/null) || continue
# Look for vault-bot comments containing VAULT:APPROVED with a JSON action spec
APPROVED_BODY=$(printf '%s' "$COMMENTS" | jq -r '
[.[] | select(.user.login == "vault-bot") | select(.body | test("VAULT:APPROVED"))] | last | .body // empty
' 2>/dev/null) || continue
[ -z "$APPROVED_BODY" ] && continue
# Extract JSON action spec from fenced code block in the comment
ACTION_JSON=$(printf '%s' "$APPROVED_BODY" | sed -n '/^```json$/,/^```$/p' | sed '1d;$d')
[ -z "$ACTION_JSON" ] && continue
# Validate JSON
if ! printf '%s' "$ACTION_JSON" | jq empty 2>/dev/null; then
log "malformed action JSON in vault-bot comment on issue #${ISSUE_NUM}"
continue
fi
ACTION_ID=$(printf '%s' "$ACTION_JSON" | jq -r '.id // empty')
if [ -z "$ACTION_ID" ]; then
ACTION_ID="issue-${ISSUE_NUM}-$(date +%s)"
ACTION_JSON=$(printf '%s' "$ACTION_JSON" | jq --arg id "$ACTION_ID" '.id = $id')
fi
# Skip if this action already exists in any stage
if [ -f "${OPS_VAULT_DIR}/approved/${ACTION_ID}.json" ] || \
[ -f "${OPS_VAULT_DIR}/fired/${ACTION_ID}.json" ] || \
[ -f "${OPS_VAULT_DIR}/rejected/${ACTION_ID}.json" ]; then
continue
fi
log "vault-bot authorized action on issue #${ISSUE_NUM}: ${ACTION_ID}"
printf '%s' "$ACTION_JSON" | jq '.status = "approved"' > "${OPS_VAULT_DIR}/approved/${ACTION_ID}.json"
COMMENT_COUNT=$((COMMENT_COUNT + 1))
# Fire the action
if bash "${VAULT_SCRIPT_DIR}/vault-fire.sh" "$ACTION_ID" >> "$LOGFILE" 2>&1; then
log "fired ${ACTION_ID} from issue #${ISSUE_NUM}"
# Mark issue as processed
touch "${LOCKS_DIR}/issue-${ISSUE_NUM}.vault-fired"
else
log "ERROR: fire failed for ${ACTION_ID} from issue #${ISSUE_NUM}"
fi
done
fi
if [ "$PENDING_COUNT" -eq 0 ] && [ "$PROCURE_COUNT" -eq 0 ] && [ "$COMMENT_COUNT" -eq 0 ]; then
status "all clear — no pending items"
else
status "poll complete — ${PENDING_COUNT} action(s), ${PROCURE_COUNT} procurement(s), ${COMMENT_COUNT} comment-authorized"
fi

43
vault/vault-reject.sh Executable file
View file

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# vault-reject.sh — Move a vault action to rejected/ with reason
#
# Usage: bash vault-reject.sh <action-id> "<reason>"
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/vault-env.sh"
OPS_VAULT_DIR="${OPS_REPO_ROOT}/vault"
LOGFILE="${DISINTO_LOG_DIR}/vault/vault.log"
LOCKS_DIR="${DISINTO_LOG_DIR}/vault/.locks"
log() {
printf '[%s] vault-reject: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
}
ACTION_ID="${1:?Usage: vault-reject.sh <action-id> \"<reason>\"}"
REASON="${2:-unspecified}"
# Find the action file
ACTION_FILE=""
if [ -f "${OPS_VAULT_DIR}/pending/${ACTION_ID}.json" ]; then
ACTION_FILE="${OPS_VAULT_DIR}/pending/${ACTION_ID}.json"
elif [ -f "${OPS_VAULT_DIR}/approved/${ACTION_ID}.json" ]; then
ACTION_FILE="${OPS_VAULT_DIR}/approved/${ACTION_ID}.json"
else
log "ERROR: action $ACTION_ID not found in pending/ or approved/"
exit 1
fi
# Update with rejection metadata and move to rejected/
TMP=$(mktemp)
jq --arg reason "$REASON" --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'.status = "rejected" | .rejected_at = $ts | .reject_reason = $reason' \
"$ACTION_FILE" > "$TMP" && mv "$TMP" "${OPS_VAULT_DIR}/rejected/${ACTION_ID}.json"
rm -f "$ACTION_FILE"
# Clean up lock if present
rm -f "${LOCKS_DIR}/${ACTION_ID}.lock"
log "$ACTION_ID: rejected — $REASON"

137
vault/vault-run-action.sh Executable file
View file

@ -0,0 +1,137 @@
#!/usr/bin/env bash
# vault-run-action.sh — Execute an action inside the ephemeral vault-runner container
#
# This script is the entrypoint for the vault-runner container. It runs with
# vault secrets injected as environment variables (GITHUB_TOKEN, CLAWHUB_TOKEN,
# deploy keys, etc.) and dispatches to the appropriate action handler.
#
# The vault-runner container is ephemeral: it starts, runs the action, and is
# destroyed. Secrets exist only in container memory, never on disk.
#
# Usage: vault-run-action.sh <action-id>
set -euo pipefail
VAULT_SCRIPT_DIR="${DISINTO_VAULT_DIR:-/home/agent/disinto/vault}"
OPS_VAULT_DIR="${DISINTO_OPS_VAULT_DIR:-${VAULT_SCRIPT_DIR}}"
LOGFILE="${VAULT_SCRIPT_DIR}/vault.log"
ACTION_ID="${1:?Usage: vault-run-action.sh <action-id>}"
log() {
printf '[%s] vault-runner: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE" 2>/dev/null || \
printf '[%s] vault-runner: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >&2
}
# Find action file in approved/
ACTION_FILE="${OPS_VAULT_DIR}/approved/${ACTION_ID}.json"
if [ ! -f "$ACTION_FILE" ]; then
log "ERROR: action file not found: ${ACTION_FILE}"
echo "ERROR: action file not found: ${ACTION_FILE}" >&2
exit 1
fi
ACTION_TYPE=$(jq -r '.type // ""' < "$ACTION_FILE")
ACTION_SOURCE=$(jq -r '.source // ""' < "$ACTION_FILE")
PAYLOAD=$(jq -c '.payload // {}' < "$ACTION_FILE")
if [ -z "$ACTION_TYPE" ]; then
log "ERROR: ${ACTION_ID} has no type field"
exit 1
fi
log "${ACTION_ID}: executing type=${ACTION_TYPE} source=${ACTION_SOURCE}"
FIRE_EXIT=0
case "$ACTION_TYPE" in
webhook-call)
# HTTP call to endpoint with optional method/headers/body
ENDPOINT=$(echo "$PAYLOAD" | jq -r '.endpoint // ""')
METHOD=$(echo "$PAYLOAD" | jq -r '.method // "POST"')
REQ_BODY=$(echo "$PAYLOAD" | jq -r '.body // ""')
if [ -z "$ENDPOINT" ]; then
log "ERROR: ${ACTION_ID} webhook-call missing endpoint"
exit 1
fi
CURL_ARGS=(-sf -X "$METHOD" -o /dev/null -w "%{http_code}")
while IFS= read -r header; do
[ -n "$header" ] && CURL_ARGS+=(-H "$header")
done < <(echo "$PAYLOAD" | jq -r '.headers // {} | to_entries[] | "\(.key): \(.value)"' 2>/dev/null || true)
if [ -n "$REQ_BODY" ] && [ "$REQ_BODY" != "null" ]; then
CURL_ARGS+=(-d "$REQ_BODY")
fi
HTTP_CODE=$(curl "${CURL_ARGS[@]}" "$ENDPOINT" 2>/dev/null) || HTTP_CODE="000"
if [[ "$HTTP_CODE" =~ ^2 ]]; then
log "${ACTION_ID}: webhook-call -> HTTP ${HTTP_CODE} OK"
else
log "ERROR: ${ACTION_ID} webhook-call -> HTTP ${HTTP_CODE}"
FIRE_EXIT=1
fi
;;
promote)
# Promote a Woodpecker pipeline to a deployment environment (staging/production).
# Payload: {"repo_id": N, "pipeline": N, "environment": "staging"|"production"}
PROMOTE_REPO_ID=$(echo "$PAYLOAD" | jq -r '.repo_id // ""')
PROMOTE_PIPELINE=$(echo "$PAYLOAD" | jq -r '.pipeline // ""')
PROMOTE_ENV=$(echo "$PAYLOAD" | jq -r '.environment // ""')
if [ -z "$PROMOTE_REPO_ID" ] || [ -z "$PROMOTE_PIPELINE" ] || [ -z "$PROMOTE_ENV" ]; then
log "ERROR: ${ACTION_ID} promote missing repo_id, pipeline, or environment"
FIRE_EXIT=1
else
# Validate environment is staging or production
case "$PROMOTE_ENV" in
staging|production) ;;
*)
log "ERROR: ${ACTION_ID} promote invalid environment '${PROMOTE_ENV}' (must be staging or production)"
FIRE_EXIT=1
;;
esac
if [ "$FIRE_EXIT" -eq 0 ]; then
WP_SERVER="${WOODPECKER_SERVER:-http://woodpecker:8000}"
WP_TOKEN="${WOODPECKER_TOKEN:-}"
if [ -z "$WP_TOKEN" ]; then
log "ERROR: ${ACTION_ID} promote requires WOODPECKER_TOKEN"
FIRE_EXIT=1
else
PROMOTE_RESP=$(curl -sf -X POST \
-H "Authorization: Bearer ${WP_TOKEN}" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "event=deployment&deploy_to=${PROMOTE_ENV}" \
"${WP_SERVER}/api/repos/${PROMOTE_REPO_ID}/pipelines/${PROMOTE_PIPELINE}" 2>/dev/null) || PROMOTE_RESP=""
NEW_PIPELINE=$(printf '%s' "$PROMOTE_RESP" | jq -r '.number // empty' 2>/dev/null)
if [ -n "$NEW_PIPELINE" ]; then
log "${ACTION_ID}: promoted pipeline ${PROMOTE_PIPELINE} to ${PROMOTE_ENV} -> new pipeline #${NEW_PIPELINE}"
else
log "ERROR: ${ACTION_ID} promote API failed (repo_id=${PROMOTE_REPO_ID} pipeline=${PROMOTE_PIPELINE} env=${PROMOTE_ENV})"
FIRE_EXIT=1
fi
fi
fi
fi
;;
blog-post|social-post|email-blast|pricing-change|dns-change|stripe-charge)
HANDLER="${VAULT_SCRIPT_DIR}/handlers/${ACTION_TYPE}.sh"
if [ -x "$HANDLER" ]; then
bash "$HANDLER" "$ACTION_ID" "$PAYLOAD" 2>&1 || FIRE_EXIT=$?
else
log "ERROR: ${ACTION_ID} no handler for type '${ACTION_TYPE}' (${HANDLER} not found)"
FIRE_EXIT=1
fi
;;
*)
log "ERROR: ${ACTION_ID} unknown action type '${ACTION_TYPE}'"
FIRE_EXIT=1
;;
esac
exit "$FIRE_EXIT"