Compare commits
154 commits
fix/issue-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 17c415c27b | |||
|
|
843440428e | ||
| b560756509 | |||
|
|
9d6f7295ce | ||
| fe4ab7d447 | |||
|
|
f0f2a62f90 | ||
|
|
697f96d3aa | ||
|
|
e78ae32225 | ||
|
|
cceb711aa2 | ||
|
|
f1c41cf493 | ||
|
|
f6d0030470 | ||
|
|
addfcd619a | ||
|
|
703518ce3f | ||
|
|
a4fd46fb36 | ||
|
|
44484588d0 | ||
| 7267f68a6d | |||
|
|
a3bd8eaac3 | ||
| 39e4b73ea0 | |||
|
|
2c0fef9694 | ||
| bd458da3f4 | |||
|
|
a2d5d71c04 | ||
| 19969586e5 | |||
|
|
2db32b20dd | ||
| 898f6f6160 | |||
|
|
978dd88347 | ||
|
|
e40ea2acf2 | ||
| 0a0fd30aa9 | |||
|
|
7eacb27c62 | ||
| 01dd4132f3 | |||
|
|
ac85f86cd9 | ||
|
|
323b1d390b | ||
| cb3492a3c1 | |||
|
|
1eefd5ac72 | ||
|
|
e617999074 | ||
|
|
ad0b0e181f | ||
| 2a9239a32f | |||
|
|
941cc4ba65 | ||
| 4f5c8cee51 | |||
|
|
e9a4fc7b80 | ||
| 0f6f074b6d | |||
|
|
e8b9f07a6b | ||
|
|
ae3d6f20a0 | ||
| 964c69a060 | |||
|
|
834ba1e351 | ||
|
|
e6d5d3508a | ||
|
|
1697ab3b3e | ||
|
|
fef058081f | ||
| efe57a02c9 | |||
|
|
a7ad6eb32a | ||
| 0455040d02 | |||
|
|
d315c79866 | ||
|
|
3aca03a06b | ||
| 11773d3edf | |||
|
|
7134752525 | ||
| f23cc065b7 | |||
|
|
171b9d2ae3 | ||
| ef57031166 | |||
|
|
cbb9907135 | ||
|
|
618400369e | ||
|
|
2afb010c20 | ||
|
|
131463b077 | ||
|
|
564e2e774d | ||
| 3d46fa06b7 | |||
|
|
ee99f185e6 | ||
| b3276f5bba | |||
|
|
2d72e0e565 | ||
| 56d1c4bae9 | |||
|
|
471d24fa23 | ||
| b17f15e071 | |||
|
|
bcad5c7638 | ||
|
|
0d2ed587c1 | ||
| d9a80b3044 | |||
|
|
7f68812a96 | ||
| 61d1654a43 | |||
|
|
963d745bde | ||
| 2436e70441 | |||
|
|
da3df3e39a | ||
| 6dce181330 | |||
|
|
ff79cb15a5 | ||
| 2722795c82 | |||
|
|
e7ed5d6567 | ||
| 1ad0503ba5 | |||
|
|
657b8aff36 | ||
| 4be719bcef | |||
|
|
af8b675b36 | ||
| 29717f767b | |||
|
|
aad21dc084 | ||
| bfce7a9a06 | |||
|
|
e60e6bc3ae | ||
|
|
2c62674c7c | ||
| 083b0cc829 | |||
|
|
d9a6030127 | ||
|
|
dc545a817b | ||
| 333a6dcee7 | |||
|
|
01943edfc3 | ||
| 842e529004 | |||
|
|
39ab881b11 | ||
| 16b0a9a318 | |||
|
|
318910265e | ||
| 357c25c7f6 | |||
|
|
b64859a2a5 | ||
| 92812ccc34 | |||
| fd1a8555f6 | |||
|
|
4bcd2c275b | ||
| 9335681a72 | |||
| a049b2c486 | |||
|
|
d6d8093fa9 | ||
|
|
b49309141b | ||
| 16fc7979c5 | |||
|
|
6be0eee20b | ||
|
|
649a893184 | ||
|
|
6e34b13a05 | ||
|
|
c9ef5eb98b | ||
| fb4ffe9fb6 | |||
|
|
8ab1009b15 | ||
| 6b47f949dd | |||
|
|
b2d3af4370 | ||
| bec2e50a67 | |||
|
|
711e650190 | ||
|
|
5bcaaf7d88 | ||
|
|
f316087003 | ||
|
|
f6cb387a2e | ||
|
|
8122f2dd5d | ||
|
|
59b4cafcfc | ||
| 06da075505 | |||
|
|
cb39cbcace | ||
|
|
f3e37b1711 | ||
|
|
76a4d42a42 | ||
|
|
b30252d32b | ||
| 65ccfd730e | |||
|
|
0ccecf6ae5 | ||
|
|
120b3d3a4b | ||
|
|
499f459c19 | ||
|
|
892970f06d | ||
|
|
8814905ede | ||
| 8f891e95de | |||
|
|
4c08b7840e | ||
| 98a71f9192 | |||
| d231d21a8c | |||
|
|
ec58cb1745 | ||
|
|
1b52761336 | ||
|
|
e0fe5c80ea | ||
| d70301766c | |||
|
|
e351e02f60 | ||
|
|
3d84390a54 | ||
|
|
6b0e9b5f4d | ||
| e6b57dc9f1 | |||
| 2c5f495987 | |||
|
|
aa73ff88c4 | ||
|
|
3ce6354f4f | ||
|
|
c1939fbb9a | ||
| 7fd61e9d0e | |||
|
|
79ae7f8690 | ||
|
|
55406b1e3d |
89 changed files with 5812 additions and 2931 deletions
|
|
@ -26,8 +26,8 @@ FORGE_GARDENER_TOKEN= # [SECRET] gardener-bot API token
|
|||
FORGE_VAULT_TOKEN= # [SECRET] vault-bot API token
|
||||
FORGE_SUPERVISOR_TOKEN= # [SECRET] supervisor-bot API token
|
||||
FORGE_PREDICTOR_TOKEN= # [SECRET] predictor-bot API token
|
||||
FORGE_ACTION_TOKEN= # [SECRET] action-bot API token
|
||||
FORGE_BOT_USERNAMES=dev-bot,review-bot,planner-bot,gardener-bot,vault-bot,supervisor-bot,predictor-bot,action-bot
|
||||
FORGE_ARCHITECT_TOKEN= # [SECRET] architect-bot API token
|
||||
FORGE_BOT_USERNAMES=dev-bot,review-bot,planner-bot,gardener-bot,vault-bot,supervisor-bot,predictor-bot,architect-bot
|
||||
|
||||
# ── Backwards compatibility ───────────────────────────────────────────────
|
||||
# If CODEBERG_TOKEN is set but FORGE_TOKEN is not, env.sh falls back to
|
||||
|
|
@ -49,7 +49,7 @@ WOODPECKER_DB_NAME=woodpecker # [CONFIG] Postgres database name
|
|||
|
||||
# ── Vault-only secrets (DO NOT put these in .env) ────────────────────────
|
||||
# These tokens grant access to external systems (GitHub, ClawHub, deploy targets).
|
||||
# They live ONLY in .env.vault.enc and are injected into the ephemeral vault-runner
|
||||
# They live ONLY in .env.vault.enc and are injected into the ephemeral runner
|
||||
# container at fire time (#745). lib/env.sh explicitly unsets them so agents
|
||||
# can never hold them directly — all external actions go through vault dispatch.
|
||||
#
|
||||
|
|
@ -58,7 +58,7 @@ WOODPECKER_DB_NAME=woodpecker # [CONFIG] Postgres database name
|
|||
# (deploy keys) — SSH keys for deployment targets
|
||||
#
|
||||
# To manage vault secrets: disinto secrets edit-vault
|
||||
# See also: vault/vault-run-action.sh, vault/vault-fire.sh
|
||||
# (vault redesign in progress: PR-based approval, see #73-#77)
|
||||
|
||||
# ── Project-specific secrets ──────────────────────────────────────────────
|
||||
# Store all project secrets here so formulas reference env vars, never hardcode.
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -22,3 +22,9 @@ metrics/supervisor-metrics.jsonl
|
|||
.DS_Store
|
||||
dev/ci-fixes-*.json
|
||||
gardener/dust.jsonl
|
||||
|
||||
# Individual encrypted secrets (managed by disinto secrets add)
|
||||
secrets/
|
||||
|
||||
# Pre-built binaries for Docker builds (avoid network calls during build)
|
||||
docker/agents/bin/
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ while IFS= read -r -d '' f; do
|
|||
printf 'FAIL [syntax] %s\n' "$f"
|
||||
FAILED=1
|
||||
fi
|
||||
done < <(find dev gardener review planner supervisor lib vault action -name "*.sh" -print0 2>/dev/null)
|
||||
done < <(find dev gardener review planner supervisor architect lib vault -name "*.sh" -print0 2>/dev/null)
|
||||
echo "syntax check done"
|
||||
|
||||
# ── 2. Function-resolution check ─────────────────────────────────────────────
|
||||
|
|
@ -210,15 +210,10 @@ check_script review/review-poll.sh
|
|||
check_script planner/planner-run.sh lib/agent-session.sh lib/formula-session.sh
|
||||
check_script supervisor/supervisor-poll.sh
|
||||
check_script supervisor/update-prompt.sh
|
||||
check_script vault/vault-agent.sh
|
||||
check_script vault/vault-fire.sh
|
||||
check_script vault/vault-poll.sh
|
||||
check_script vault/vault-reject.sh
|
||||
check_script action/action-poll.sh
|
||||
check_script action/action-agent.sh
|
||||
check_script supervisor/supervisor-run.sh
|
||||
check_script supervisor/preflight.sh
|
||||
check_script predictor/predictor-run.sh
|
||||
check_script architect/architect-run.sh
|
||||
|
||||
echo "function resolution check done"
|
||||
|
||||
|
|
|
|||
|
|
@ -179,10 +179,17 @@ def collect_findings(root):
|
|||
Returns ``(ap_hits, dup_groups)`` with file paths relative to *root*.
|
||||
"""
|
||||
root = Path(root)
|
||||
sh_files = sorted(
|
||||
p for p in root.rglob("*.sh") if ".git" not in p.parts
|
||||
# Skip architect scripts for duplicate detection (stub formulas, see #99)
|
||||
EXCLUDED_SUFFIXES = ("architect/architect-run.sh",)
|
||||
|
||||
def is_excluded(p):
|
||||
"""Check if path should be excluded by suffix match."""
|
||||
return p.suffix == ".sh" and ".git" not in p.parts and any(
|
||||
str(p).endswith(suffix) for suffix in EXCLUDED_SUFFIXES
|
||||
)
|
||||
|
||||
sh_files = sorted(p for p in root.rglob("*.sh") if not is_excluded(p))
|
||||
|
||||
ap_hits = check_anti_patterns(sh_files)
|
||||
dup_groups = check_duplicates(sh_files)
|
||||
|
||||
|
|
@ -238,10 +245,30 @@ def print_duplicates(groups, label=""):
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> int:
|
||||
sh_files = sorted(
|
||||
p for p in Path(".").rglob("*.sh") if ".git" not in p.parts
|
||||
# Skip architect scripts for duplicate detection (stub formulas, see #99)
|
||||
EXCLUDED_SUFFIXES = ("architect/architect-run.sh",)
|
||||
|
||||
def is_excluded(p):
|
||||
"""Check if path should be excluded by suffix match."""
|
||||
return p.suffix == ".sh" and ".git" not in p.parts and any(
|
||||
str(p).endswith(suffix) for suffix in EXCLUDED_SUFFIXES
|
||||
)
|
||||
|
||||
sh_files = sorted(p for p in Path(".").rglob("*.sh") if not is_excluded(p))
|
||||
|
||||
# Standard patterns that are intentionally repeated across formula-driven agents
|
||||
# These are not copy-paste violations but the expected structure
|
||||
ALLOWED_HASHES = {
|
||||
# Standard agent header: shebang, set -euo pipefail, directory resolution
|
||||
"c93baa0f19d6b9ba271428bf1cf20b45": "Standard agent header (set -euo pipefail, SCRIPT_DIR, FACTORY_ROOT)",
|
||||
# formula_prepare_profile_context followed by scratch context reading
|
||||
"eaa735b3598b7b73418845ab00d8aba5": "Standard .profile context setup (formula_prepare_profile_context + SCRATCH_CONTEXT)",
|
||||
# Standard prompt template: GRAPH_SECTION, SCRATCH_CONTEXT, FORMULA_CONTENT, SCRATCH_INSTRUCTION
|
||||
"2653705045fdf65072cccfd16eb04900": "Standard prompt template (GRAPH_SECTION, SCRATCH_CONTEXT, FORMULA_CONTENT)",
|
||||
"93726a3c799b72ed2898a55552031921": "Standard prompt template continuation (SCRATCH_CONTEXT, FORMULA_CONTENT, SCRATCH_INSTRUCTION)",
|
||||
"c11eaaacab69c9a2d3c38c75215eca84": "Standard prompt template end (FORMULA_CONTENT, SCRATCH_INSTRUCTION)",
|
||||
}
|
||||
|
||||
if not sh_files:
|
||||
print("No .sh files found.")
|
||||
return 0
|
||||
|
|
@ -276,8 +303,13 @@ def main() -> int:
|
|||
|
||||
# Duplicate diff: key by content hash
|
||||
base_dup_hashes = {g[0] for g in base_dups}
|
||||
new_dups = [g for g in cur_dups if g[0] not in base_dup_hashes]
|
||||
pre_dups = [g for g in cur_dups if g[0] in base_dup_hashes]
|
||||
# Filter out allowed standard patterns that are intentionally repeated
|
||||
new_dups = [
|
||||
g for g in cur_dups
|
||||
if g[0] not in base_dup_hashes and g[0] not in ALLOWED_HASHES
|
||||
]
|
||||
# Also filter allowed hashes from pre_dups for reporting
|
||||
pre_dups = [g for g in cur_dups if g[0] in base_dup_hashes and g[0] not in ALLOWED_HASHES]
|
||||
|
||||
# Report pre-existing as info
|
||||
if pre_ap or pre_dups:
|
||||
|
|
|
|||
|
|
@ -1,37 +1,17 @@
|
|||
# .woodpecker/smoke-init.yml — End-to-end smoke test for disinto init
|
||||
#
|
||||
# Uses the Forgejo image directly (not as a service) so we have CLI
|
||||
# access to set up Forgejo and create the bootstrap admin user.
|
||||
# Then runs disinto init --bare --yes against the local Forgejo instance.
|
||||
#
|
||||
# Forgejo refuses to run as root, so all forgejo commands use su-exec
|
||||
# to run as the 'git' user (pre-created in the Forgejo Docker image).
|
||||
|
||||
when:
|
||||
event: [push, pull_request]
|
||||
- event: pull_request
|
||||
path:
|
||||
- "bin/disinto"
|
||||
- "lib/load-project.sh"
|
||||
- "tests/smoke-init.sh"
|
||||
- "lib/env.sh"
|
||||
- "tests/**"
|
||||
- ".woodpecker/smoke-init.yml"
|
||||
- "docker/**"
|
||||
|
||||
steps:
|
||||
- name: smoke-init
|
||||
image: codeberg.org/forgejo/forgejo:11.0
|
||||
environment:
|
||||
SMOKE_FORGE_URL: http://localhost:3000
|
||||
image: python:3-alpine
|
||||
commands:
|
||||
# Install test dependencies (Alpine-based image)
|
||||
- apk add --no-cache bash curl jq python3 git >/dev/null 2>&1
|
||||
# Set up Forgejo data directories and config (owned by git user)
|
||||
- mkdir -p /data/gitea/conf /data/gitea/repositories /data/gitea/lfs /data/gitea/log /data/git/.ssh /data/ssh
|
||||
- printf '[database]\nDB_TYPE = sqlite3\nPATH = /data/gitea/forgejo.db\n\n[server]\nHTTP_PORT = 3000\nROOT_URL = http://localhost:3000/\nLFS_START_SERVER = false\n\n[security]\nINSTALL_LOCK = true\n\n[service]\nDISABLE_REGISTRATION = true\n' > /data/gitea/conf/app.ini
|
||||
- chown -R git:git /data
|
||||
# Start Forgejo as git user in background and wait for API
|
||||
- su-exec git forgejo web --config /data/gitea/conf/app.ini &
|
||||
- for i in $(seq 1 30); do curl -sf http://localhost:3000/api/v1/version >/dev/null 2>&1 && break; sleep 1; done
|
||||
# Create bootstrap admin user via CLI
|
||||
- su-exec git forgejo admin user create --admin --username setup-admin --password "SetupPass-789xyz" --email "setup-admin@smoke.test" --must-change-password=false --config /data/gitea/conf/app.ini
|
||||
# Run the smoke test (as root is fine — only forgejo binary needs git user)
|
||||
- apk add --no-cache bash curl jq git coreutils
|
||||
- python3 tests/mock-forgejo.py &
|
||||
- sleep 2
|
||||
- bash tests/smoke-init.sh
|
||||
|
|
|
|||
68
AGENTS.md
68
AGENTS.md
|
|
@ -3,10 +3,17 @@
|
|||
|
||||
## What this repo is
|
||||
|
||||
Disinto is an autonomous code factory. It manages eight agents (dev, review,
|
||||
gardener, supervisor, planner, predictor, action, vault) that pick up issues from forge,
|
||||
implement them, review PRs, plan from the vision, gate dangerous actions, and
|
||||
keep the system healthy — all via cron and `claude -p`.
|
||||
Disinto is an autonomous code factory. It manages seven agents (dev, review,
|
||||
gardener, supervisor, planner, predictor, architect) that pick up issues from
|
||||
forge, implement them, review PRs, plan from the vision, and keep the system
|
||||
healthy — all via cron and `claude -p`. The dispatcher executes formula-based
|
||||
operational tasks.
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -22,9 +29,9 @@ disinto/ (code repo)
|
|||
├── supervisor/ supervisor-run.sh — formula-driven health monitoring (cron wrapper)
|
||||
│ preflight.sh — pre-flight data collection for supervisor formula
|
||||
│ supervisor-poll.sh — legacy bash orchestrator (superseded)
|
||||
├── vault/ vault-poll.sh, vault-agent.sh, vault-fire.sh — action gating + procurement
|
||||
├── action/ action-poll.sh, action-agent.sh — operational task execution
|
||||
├── lib/ env.sh, agent-session.sh, ci-helpers.sh, ci-debug.sh, load-project.sh, parse-deps.sh, guard.sh, mirrors.sh, pr-lifecycle.sh, issue-lifecycle.sh, worktree.sh, build-graph.py
|
||||
├── architect/ architect-run.sh — strategic decomposition of vision into sprints
|
||||
├── vault/ vault-env.sh — shared env setup (vault redesign in progress, see #73-#77)
|
||||
├── lib/ env.sh, agent-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
|
||||
├── projects/ *.toml.example — templates; *.toml — local per-box config (gitignored)
|
||||
├── formulas/ Issue templates (TOML specs for multi-step agent tasks)
|
||||
└── docs/ Protocol docs (PHASE-PROTOCOL.md, EVIDENCE-ARCHITECTURE.md)
|
||||
|
|
@ -35,9 +42,6 @@ disinto-ops/ (ops repo — {project}-ops)
|
|||
│ ├── approved/ approved vault items
|
||||
│ ├── fired/ executed vault items
|
||||
│ └── rejected/ rejected vault items
|
||||
├── journal/
|
||||
│ ├── planner/ daily planning logs
|
||||
│ └── supervisor/ operational health logs
|
||||
├── knowledge/ shared agent knowledge + best practices
|
||||
├── evidence/ engagement data, experiment results
|
||||
├── portfolio.md addressables + observables
|
||||
|
|
@ -45,6 +49,35 @@ disinto-ops/ (ops repo — {project}-ops)
|
|||
└── RESOURCES.md accounts, tokens (refs), infra inventory
|
||||
```
|
||||
|
||||
> **Note:** Journal directories (`journal/planner/` and `journal/supervisor/`) have been removed from the ops repo. Agent journals are now stored in each agent's `.profile` repo on Forgejo.
|
||||
|
||||
## Agent .profile Model
|
||||
|
||||
Each agent has a `.profile` repository on Forgejo that stores:
|
||||
- `formula.toml` — agent-specific formula (optional, falls back to `formulas/<agent>.toml`)
|
||||
- `knowledge/lessons-learned.md` — distilled lessons from journal entries
|
||||
- `journal/` — session reflection entries (archived after digestion)
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Pre-session:** The agent calls `formula_prepare_profile_context()` which:
|
||||
- Resolves the agent's Forgejo identity from their token
|
||||
- Clones/pulls the `.profile` repo to a local cache
|
||||
- Loads `knowledge/lessons-learned.md` into `LESSONS_CONTEXT` for prompt injection
|
||||
- Automatically digests journals if >10 undigested entries exist
|
||||
|
||||
2. **Prompt injection:** Lessons are injected into the agent prompt:
|
||||
```
|
||||
## Lessons learned (from .profile/knowledge/lessons-learned.md)
|
||||
<abstracted lessons from prior sessions>
|
||||
```
|
||||
|
||||
3. **Post-session:** The agent calls `profile_write_journal` which:
|
||||
- Generates a reflection entry about the session
|
||||
- Writes it to `journal/issue-{N}.md`
|
||||
- Commits and pushes to the `.profile` repo
|
||||
- Journals are archived after being digested into lessons-learned.md
|
||||
|
||||
> **Terminology note:** "Formulas" in this repo are TOML issue templates in `formulas/` that
|
||||
> orchestrate multi-step agent tasks (e.g., `run-gardener.toml`, `run-planner.toml`). This is
|
||||
> distinct from "processes" described in `docs/EVIDENCE-ARCHITECTURE.md`, which are measurement
|
||||
|
|
@ -90,8 +123,10 @@ bash dev/phase-test.sh
|
|||
| Supervisor | `supervisor/` | Health monitoring | [supervisor/AGENTS.md](supervisor/AGENTS.md) |
|
||||
| Planner | `planner/` | Strategic planning | [planner/AGENTS.md](planner/AGENTS.md) |
|
||||
| Predictor | `predictor/` | Infrastructure pattern detection | [predictor/AGENTS.md](predictor/AGENTS.md) |
|
||||
| Action | `action/` | Operational task execution | [action/AGENTS.md](action/AGENTS.md) |
|
||||
| Vault | `vault/` | Action gating + resource procurement | [vault/AGENTS.md](vault/AGENTS.md) |
|
||||
| Architect | `architect/` | Strategic decomposition | [architect/AGENTS.md](architect/AGENTS.md) |
|
||||
|
||||
> **Vault:** Being redesigned as a PR-based approval workflow (issues #73-#77).
|
||||
> See [docs/VAULT.md](docs/VAULT.md) for the vault PR workflow details.
|
||||
|
||||
See [lib/AGENTS.md](lib/AGENTS.md) for the full shared helper reference.
|
||||
|
||||
|
|
@ -108,14 +143,13 @@ Issues flow: `backlog` → `in-progress` → PR → CI → review → merge →
|
|||
| `backlog` | Issue is queued for implementation. Dev-poll picks the first ready one. | Planner, gardener, humans |
|
||||
| `priority` | Queue tier above plain backlog. Issues with both `priority` and `backlog` are picked before plain `backlog` issues. FIFO within each tier. | Planner, humans |
|
||||
| `in-progress` | Dev-agent is actively working on this issue. Only one issue per project is in-progress at a time. | dev-agent.sh (claims issue) |
|
||||
| `blocked` | Issue is stuck — agent session failed, crashed, timed out, or CI exhausted. Diagnostic comment on the issue has details. Also used for unmet dependencies. | dev-agent.sh, action-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, dev-poll.sh (on failure) |
|
||||
| `tech-debt` | Pre-existing issue flagged by AI reviewer, not introduced by a PR. | review-pr.sh (auto-created follow-ups) |
|
||||
| `underspecified` | Dev-agent refused the issue as too large or vague. | dev-poll.sh (on preflight `too_large`), dev-agent.sh (on mid-run `too_large` refusal) |
|
||||
| `vision` | Goal anchors — high-level objectives from VISION.md. | Planner, humans |
|
||||
| `prediction/unreviewed` | Unprocessed prediction filed by predictor. | predictor-run.sh |
|
||||
| `prediction/dismissed` | Prediction triaged as DISMISS — planner disagrees, closed with reason. | Planner (triage-predictions step) |
|
||||
| `prediction/actioned` | Prediction promoted or dismissed by planner. | Planner (triage-predictions step) |
|
||||
| `action` | Operational task for the action-agent to execute via formula. | Planner, humans |
|
||||
|
||||
### Dependency conventions
|
||||
|
||||
|
|
@ -160,12 +194,12 @@ Humans write these. Agents read and enforce them.
|
|||
|
||||
| ID | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| 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-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-002 | Single-threaded pipeline per project. | One dev issue at a time. No new work while a PR awaits CI or review. Prevents merge conflicts and keeps context clear. |
|
||||
| AD-003 | The runtime creates and destroys, the formula preserves. | Runtime manages worktrees/sessions/temp. Formulas commit knowledge to git before signaling done. |
|
||||
| AD-004 | Event-driven > polling > fixed delays. | Never `waitForTimeout` or hardcoded sleep. Use phase files, webhooks, or poll loops with backoff. |
|
||||
| AD-005 | Secrets via env var indirection, never in issue bodies. | Issue bodies become code. Agent secrets go in `.env.enc`, vault secrets in `.env.vault.enc` (both SOPS-encrypted). Referenced as `$VAR_NAME`. Vault-runner gets only vault secrets; agents get only agent secrets. |
|
||||
| AD-006 | External actions go through vault dispatch, never direct. | Agents build addressables; only the vault exercises them (publishes, deploys, posts). Tokens for external systems (`GITHUB_TOKEN`, `CLAWHUB_TOKEN`, deploy keys) live only in `.env.vault.enc` and are injected into the ephemeral vault-runner container. `lib/env.sh` unsets them so agents never hold them. PRs with direct external actions without vault dispatch get REQUEST_CHANGES. |
|
||||
| AD-005 | Secrets via env var indirection, never in issue bodies. | Issue bodies become code. Agent secrets go in `.env.enc`, vault secrets in `.env.vault.enc` (both SOPS-encrypted). Referenced as `$VAR_NAME`. Runner gets only vault secrets; agents get only agent secrets. |
|
||||
| AD-006 | External actions go through vault dispatch, never direct. | Agents build addressables; only the vault exercises them (publishes, deploys, posts). Tokens for external systems (`GITHUB_TOKEN`, `CLAWHUB_TOKEN`, deploy keys) live only in `.env.vault.enc` and are injected into the ephemeral runner container. `lib/env.sh` unsets them so agents never hold them. PRs with direct external actions without vault dispatch get REQUEST_CHANGES. (Vault redesign in progress: PR-based approval on ops repo, see #73-#77) |
|
||||
|
||||
**Who enforces what:**
|
||||
- **Gardener** checks open backlog issues against ADs during grooming; closes violations with a comment referencing the AD number.
|
||||
|
|
|
|||
19
README.md
19
README.md
|
|
@ -37,9 +37,6 @@ cron (daily) ──→ gardener-poll.sh ← backlog grooming (duplicates, stale
|
|||
cron (weekly) ──→ planner-poll.sh ← gap-analyse VISION.md, create backlog issues
|
||||
└── claude -p: update AGENTS.md → create issues
|
||||
|
||||
cron (*/30) ──→ vault-poll.sh ← safety gate for dangerous/irreversible actions
|
||||
└── claude -p: classify → auto-approve/reject or escalate
|
||||
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
|
@ -96,7 +93,6 @@ crontab -e
|
|||
# 3,13,23,33,43,53 * * * * /path/to/disinto/review/review-poll.sh
|
||||
# 6,16,26,36,46,56 * * * * /path/to/disinto/dev/dev-poll.sh
|
||||
# 15 8 * * * /path/to/disinto/gardener/gardener-poll.sh
|
||||
# 0,30 * * * * /path/to/disinto/vault/vault-poll.sh
|
||||
# 0 9 * * 1 /path/to/disinto/planner/planner-poll.sh
|
||||
|
||||
# 4. Verify
|
||||
|
|
@ -123,16 +119,13 @@ disinto/
|
|||
│ └── best-practices.md # Gardener knowledge base
|
||||
├── planner/
|
||||
│ ├── planner-poll.sh # Cron entry: weekly vision gap analysis
|
||||
│ └── (formula-driven) # run-planner.toml executed by action-agent
|
||||
│ └── (formula-driven) # run-planner.toml executed by dispatcher
|
||||
├── vault/
|
||||
│ ├── vault-poll.sh # Cron entry: process pending dangerous actions
|
||||
│ ├── vault-agent.sh # Classifies and routes actions (claude -p)
|
||||
│ ├── vault-fire.sh # Executes an approved action
|
||||
│ ├── vault-reject.sh # Marks an action as rejected
|
||||
│ └── PROMPT.md # System prompt for vault agent
|
||||
│ └── vault-env.sh # Shared env setup (vault redesign in progress, see #73-#77)
|
||||
├── docs/
|
||||
│ └── VAULT.md # Vault PR workflow and branch protection documentation
|
||||
└── supervisor/
|
||||
├── supervisor-poll.sh # Supervisor: health checks + claude -p
|
||||
├── PROMPT.md # Supervisor's system prompt
|
||||
├── update-prompt.sh # Self-learning: append to best-practices
|
||||
└── best-practices/ # Progressive disclosure knowledge base
|
||||
├── memory.md
|
||||
|
|
@ -153,7 +146,9 @@ disinto/
|
|||
| **Review** | Every 10 min | Finds PRs without review, runs Claude-powered code review, approves or requests changes. |
|
||||
| **Gardener** | Daily | Grooms the issue backlog: detects duplicates, promotes `tech-debt` to `backlog`, closes stale issues, escalates ambiguous items. |
|
||||
| **Planner** | Weekly | Updates AGENTS.md documentation to reflect recent code changes, then gap-analyses VISION.md vs current state and creates up to 5 backlog issues for the highest-leverage gaps. |
|
||||
| **Vault** | 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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
<!-- 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.
|
||||
|
|
@ -1,323 +0,0 @@
|
|||
#!/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}"
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
#!/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
|
||||
65
architect/AGENTS.md
Normal file
65
architect/AGENTS.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<!-- 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
|
||||
107
architect/architect-run.sh
Executable file
107
architect/architect-run.sh
Executable file
|
|
@ -0,0 +1,107 @@
|
|||
#!/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 ---"
|
||||
|
||||
# ── Load formula + context ───────────────────────────────────────────────
|
||||
load_formula "$FACTORY_ROOT/formulas/run-architect.toml"
|
||||
build_context_block VISION.md AGENTS.md ops:prerequisites.md
|
||||
|
||||
# ── 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
|
||||
${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"
|
||||
log "--- Architect run done ---"
|
||||
899
bin/disinto
899
bin/disinto
File diff suppressed because it is too large
Load diff
|
|
@ -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.
|
||||
|
||||
**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`, `action`, `prediction/dismissed`, or `prediction/unreviewed` (replaced `prediction/backlog` — that label no longer exists)
|
||||
- `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` (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/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
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ source "$(dirname "$0")/../lib/worktree.sh"
|
|||
source "$(dirname "$0")/../lib/pr-lifecycle.sh"
|
||||
source "$(dirname "$0")/../lib/mirrors.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
|
||||
git -C "$FACTORY_ROOT" pull --ff-only origin main 2>/dev/null || true
|
||||
|
|
@ -40,7 +41,7 @@ REPO_ROOT="${PROJECT_REPO_ROOT}"
|
|||
|
||||
LOCKFILE="/tmp/dev-agent-${PROJECT_NAME:-default}.lock"
|
||||
STATUSFILE="/tmp/dev-agent-status-${PROJECT_NAME:-default}"
|
||||
BRANCH="fix/issue-${ISSUE}"
|
||||
BRANCH="fix/issue-${ISSUE}" # Default; will be updated after FORGE_REMOTE is known
|
||||
WORKTREE="/tmp/${PROJECT_NAME}-worktree-${ISSUE}"
|
||||
SID_FILE="/tmp/dev-session-${PROJECT_NAME}-${ISSUE}.sid"
|
||||
PREFLIGHT_RESULT="/tmp/dev-agent-preflight.json"
|
||||
|
|
@ -185,7 +186,11 @@ log "preflight passed"
|
|||
# =============================================================================
|
||||
# CLAIM ISSUE
|
||||
# =============================================================================
|
||||
issue_claim "$ISSUE"
|
||||
if ! issue_claim "$ISSUE"; then
|
||||
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
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -258,6 +263,19 @@ FORGE_REMOTE="${FORGE_REMOTE:-origin}"
|
|||
export 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 ! worktree_recover "$WORKTREE" "$BRANCH" "$FORGE_REMOTE"; then
|
||||
log "ERROR: worktree recovery failed"
|
||||
|
|
@ -302,6 +320,10 @@ OPEN_ISSUES_SUMMARY=$(forge_api GET "/issues?state=open&labels=backlog&limit=20&
|
|||
|
||||
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
|
||||
GIT_DIFF_STAT=$(git -C "$WORKTREE" diff "${FORGE_REMOTE}/${PRIMARY_BRANCH}..HEAD" --stat 2>/dev/null \
|
||||
| head -20 || echo "(no diff)")
|
||||
|
|
@ -332,6 +354,10 @@ ${GIT_DIFF_STAT}
|
|||
3. Address any pending review comments or CI failures.
|
||||
4. Commit and push to \`${BRANCH}\`.
|
||||
|
||||
${LESSONS_INJECTION:+## Lessons learned
|
||||
${LESSONS_INJECTION}
|
||||
|
||||
}
|
||||
${PUSH_INSTRUCTIONS}"
|
||||
else
|
||||
INITIAL_PROMPT="You are working in a git worktree at ${WORKTREE} on branch ${BRANCH}.
|
||||
|
|
@ -347,6 +373,10 @@ ${OPEN_ISSUES_SUMMARY}
|
|||
$(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"
|
||||
fi)
|
||||
${LESSONS_INJECTION:+## Lessons learned
|
||||
${LESSONS_INJECTION}
|
||||
|
||||
}
|
||||
## Instructions
|
||||
|
||||
1. Read AGENTS.md in this repo for project context and coding conventions.
|
||||
|
|
@ -450,6 +480,40 @@ Closing as already implemented."
|
|||
fi
|
||||
|
||||
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}"
|
||||
CLAIMED=false
|
||||
worktree_cleanup "$WORKTREE"
|
||||
|
|
@ -497,6 +561,12 @@ if [ "$rc" -eq 0 ]; then
|
|||
log "PR #${PR_NUMBER} merged"
|
||||
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
|
||||
git -C "$REPO_ROOT" fetch "$FORGE_REMOTE" "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
git -C "$REPO_ROOT" checkout "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
|
|
@ -510,6 +580,18 @@ else
|
|||
# Exhausted or unrecoverable failure
|
||||
log "PR walk failed: ${_PR_WALK_EXIT_REASON:-unknown}"
|
||||
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
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -155,9 +155,10 @@ try_direct_merge() {
|
|||
if [ "$issue_num" -gt 0 ]; then
|
||||
issue_close "$issue_num"
|
||||
# Remove in-progress label (don't re-add backlog — issue is closed)
|
||||
IP_ID=$(_ilc_in_progress_id)
|
||||
curl -sf -X DELETE \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${API}/issues/${issue_num}/labels/in-progress" >/dev/null 2>&1 || true
|
||||
"${API}/issues/${issue_num}/labels/${IP_ID}" >/dev/null 2>&1 || true
|
||||
rm -f "/tmp/dev-session-${PROJECT_NAME}-${issue_num}.sid" \
|
||||
"/tmp/dev-impl-summary-${PROJECT_NAME}-${issue_num}.txt"
|
||||
fi
|
||||
|
|
@ -307,6 +308,11 @@ memory_guard 2000
|
|||
# PRIORITY 1: orphaned 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}" \
|
||||
"${API}/issues?state=open&labels=in-progress&limit=10&type=issues")
|
||||
|
||||
|
|
@ -317,11 +323,12 @@ if [ "$ORPHAN_COUNT" -gt 0 ]; then
|
|||
# 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).
|
||||
ORPHAN_LABELS=$(echo "$ORPHANS_JSON" | jq -r '.[0].labels[].name' 2>/dev/null) || true
|
||||
SKIP_LABEL=$(echo "$ORPHAN_LABELS" | grep -oE '^(formula|action|prediction/dismissed|prediction/unreviewed)$' | head -1) || true
|
||||
SKIP_LABEL=$(echo "$ORPHAN_LABELS" | grep -oE '^(formula|prediction/dismissed|prediction/unreviewed)$' | head -1) || true
|
||||
if [ -n "$SKIP_LABEL" ]; then
|
||||
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}" \
|
||||
"${API}/issues/${ISSUE_NUM}/labels/in-progress" >/dev/null 2>&1 || true
|
||||
"${API}/issues/${ISSUE_NUM}/labels/${IP_ID}" >/dev/null 2>&1 || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
|
@ -332,6 +339,26 @@ if [ "$ORPHAN_COUNT" -gt 0 ]; then
|
|||
'.[] | select(.head.ref == $branch) | .number' | head -1) || true
|
||||
|
||||
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}" \
|
||||
"${API}/pulls/${HAS_PR}" | jq -r '.head.sha') || true
|
||||
CI_STATE=$(ci_commit_status "$PR_SHA") || true
|
||||
|
|
@ -385,9 +412,24 @@ if [ "$ORPHAN_COUNT" -gt 0 ]; then
|
|||
|
||||
else
|
||||
log "issue #${ISSUE_NUM} has open PR #${HAS_PR} (CI: ${CI_STATE}, waiting)"
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
log "recovering orphaned issue #${ISSUE_NUM} (no PR found)"
|
||||
# Check assignee before adopting orphaned issue
|
||||
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 &
|
||||
log "started dev-agent PID $! for issue #${ISSUE_NUM} (recovery)"
|
||||
exit 0
|
||||
|
|
@ -523,7 +565,7 @@ for i in $(seq 0 $((BACKLOG_COUNT - 1))); do
|
|||
|
||||
# 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
|
||||
SKIP_LABEL=$(echo "$ISSUE_LABELS" | grep -oE '^(formula|action|prediction/dismissed|prediction/unreviewed)$' | head -1) || true
|
||||
SKIP_LABEL=$(echo "$ISSUE_LABELS" | grep -oE '^(formula|prediction/dismissed|prediction/unreviewed)$' | head -1) || true
|
||||
if [ -n "$SKIP_LABEL" ]; then
|
||||
log "issue #${ISSUE_NUM} has '${SKIP_LABEL}' label — skipping in backlog scan"
|
||||
continue
|
||||
|
|
@ -540,6 +582,26 @@ for i in $(seq 0 $((BACKLOG_COUNT - 1))); do
|
|||
'.[] | select((.head.ref == $branch) or (.title | contains($num))) | .number' | head -1) || true
|
||||
|
||||
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}" \
|
||||
"${API}/pulls/${EXISTING_PR}" | jq -r '.head.sha') || true
|
||||
CI_STATE=$(ci_commit_status "$PR_SHA") || true
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ You are helping the user set up and operate a **disinto autonomous code factory*
|
|||
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.
|
||||
|
||||
This guide shows how to set up the factory to develop an **external project** (e.g., `johba/harb`).
|
||||
|
||||
## First-time setup
|
||||
|
||||
Walk the user through these steps interactively. Ask questions where marked with [ASK].
|
||||
|
|
@ -27,20 +29,34 @@ docker --version && git --version && jq --version && curl --version && tmux -V &
|
|||
|
||||
Any missing tool — help the user install it before continuing.
|
||||
|
||||
### 2. Clone and init
|
||||
### 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 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`
|
||||
[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)
|
||||
|
||||
Run the init and watch for:
|
||||
- All bot users created (dev-bot, review-bot, etc.)
|
||||
- `WOODPECKER_TOKEN` generated and saved
|
||||
- Stack containers all started
|
||||
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
|
||||
|
||||
|
|
@ -70,7 +86,48 @@ 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)
|
||||
### 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
|
||||
|
|
@ -88,7 +145,7 @@ 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:
|
||||
Edit `projects/<name>.toml` to uncomment and configure mirrors:
|
||||
```toml
|
||||
[mirrors]
|
||||
github = "git@github.com:Org/repo.git"
|
||||
|
|
@ -100,7 +157,7 @@ Test with a manual push:
|
|||
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
|
||||
### 6. Seed the backlog
|
||||
|
||||
[ASK] What should the factory work on first? Brainstorm with the user.
|
||||
|
||||
|
|
@ -128,10 +185,12 @@ Use labels:
|
|||
- `blocked` — parked, not for the factory
|
||||
- No label — tracked but not for autonomous work
|
||||
|
||||
### 6. Watch it 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"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
bash curl git jq tmux cron python3 openssh-client ca-certificates \
|
||||
bash curl git jq tmux cron python3 python3-pip openssh-client ca-certificates age shellcheck \
|
||||
&& pip3 install --break-system-packages networkx \
|
||||
&& 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
|
||||
# Checksum from https://dl.gitea.com/tea/0.9.2/tea-0.9.2-linux-amd64.sha256
|
||||
RUN curl -sL https://dl.gitea.com/tea/0.9.2/tea-0.9.2-linux-amd64 -o /usr/local/bin/tea \
|
||||
&& echo "be10cdf9a619e3c0f121df874960ed19b53e62d1c7036cf60313a28b5227d54d /usr/local/bin/tea" | sha256sum -c - \
|
||||
&& chmod +x /usr/local/bin/tea
|
||||
COPY docker/agents/bin/tea /usr/local/bin/tea
|
||||
RUN chmod +x /usr/local/bin/tea
|
||||
|
||||
# Claude CLI is mounted from the host via docker-compose volume.
|
||||
# No internet access to cli.anthropic.com required at build time.
|
||||
|
|
@ -16,11 +20,14 @@ RUN curl -sL https://dl.gitea.com/tea/0.9.2/tea-0.9.2-linux-amd64 -o /usr/local/
|
|||
# Non-root user
|
||||
RUN useradd -m -u 1000 -s /bin/bash agent
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
# Copy disinto code into the image
|
||||
COPY . /home/agent/disinto
|
||||
|
||||
COPY docker/agents/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Entrypoint runs as root to start the cron daemon;
|
||||
# cron jobs execute as the agent user (crontab -u agent).
|
||||
WORKDIR /home/agent
|
||||
WORKDIR /home/agent/disinto
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
|
|
|||
45
docker/agents/entrypoint-llama.sh
Executable file
45
docker/agents/entrypoint-llama.sh
Executable file
|
|
@ -0,0 +1,45 @@
|
|||
#!/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:-johba/disinto}.git ${PROJECT_REPO_ROOT}"
|
||||
log "Repo cloned"
|
||||
fi
|
||||
|
||||
log "Entering poll loop (interval: ${POLL_INTERVAL:-300}s)"
|
||||
|
||||
while true; do
|
||||
# Clear stale session IDs before each poll.
|
||||
# Local llama does not support --resume (no server-side session storage).
|
||||
# Stale .sid files cause agent_run to exit instantly on every retry.
|
||||
rm -f /tmp/dev-session-*.sid 2>/dev/null || true
|
||||
|
||||
su -s /bin/bash agent -c "
|
||||
export FORGE_TOKEN='${FORGE_TOKEN}'
|
||||
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
|
||||
|
|
@ -19,7 +19,9 @@ log() {
|
|||
# Build crontab from project TOMLs and install for the agent user.
|
||||
install_project_crons() {
|
||||
local cron_lines="DISINTO_CONTAINER=1
|
||||
USER=agent"
|
||||
USER=agent
|
||||
FORGE_URL=http://forgejo:3000
|
||||
PROJECT_REPO_ROOT=/home/agent/repos/${pname}"
|
||||
for toml in "${DISINTO_DIR}"/projects/*.toml; do
|
||||
[ -f "$toml" ] || continue
|
||||
local pname
|
||||
|
|
|
|||
4
docker/edge/Dockerfile
Normal file
4
docker/edge/Dockerfile
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
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"]
|
||||
421
docker/edge/dispatcher.sh
Executable file
421
docker/edge/dispatcher.sh
Executable file
|
|
@ -0,0 +1,421 @@
|
|||
#!/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 compose run --rm runner <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
|
||||
|
||||
# Fetch user info from Forgejo API
|
||||
user_json=$(curl -sf -H "Authorization: token ${FORGE_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
|
||||
}' || true
|
||||
}
|
||||
|
||||
# Verify vault action arrived via admin-merged PR
|
||||
# Usage: verify_admin_merged <toml_file>
|
||||
# Returns: 0=verified, 1=not verified
|
||||
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}"
|
||||
|
||||
# Get PR merger info
|
||||
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}'"
|
||||
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 compose run --rm runner)
|
||||
|
||||
# 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")
|
||||
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 (after service name)
|
||||
local formula="${VAULT_ACTION_FORMULA:-}"
|
||||
cmd+=("$formula" "$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 "$@"
|
||||
19
docker/edge/entrypoint-edge.sh
Executable file
19
docker/edge/entrypoint-edge.sh
Executable file
|
|
@ -0,0 +1,19 @@
|
|||
#!/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}/johba/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
|
||||
|
|
@ -114,4 +114,3 @@ When reviewing PRs or designing new agents, ask:
|
|||
| gardener | 1242 (agent 471 + poll 771) | Medium — backlog triage, duplicate detection, tech-debt scoring | Poll is heavy orchestration; agent is prompt-driven |
|
||||
| vault | 442 (4 scripts) | Medium — approval flow, human gate decisions | Intentionally bash-heavy (security gate should be deterministic) |
|
||||
| planner | 382 | Medium — AGENTS.md update, gap analysis | Tmux+formula (done, #232) |
|
||||
| action-agent | 192 | Light — formula execution | Close to target |
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ signal to the phase file.
|
|||
- **Post-loop exit handler (`case $_MONITOR_LOOP_EXIT`):** Must include an
|
||||
`idle_prompt)` branch. Typical actions: log the event, clean up temp files,
|
||||
and (for agents that use escalation) write an escalation entry or notify via
|
||||
vault/forge. See `dev/dev-agent.sh`, `action/action-agent.sh`, and
|
||||
vault/forge. See `dev/dev-agent.sh` and
|
||||
`gardener/gardener-agent.sh` for reference implementations.
|
||||
|
||||
## Crash Recovery
|
||||
|
|
|
|||
98
docs/VAULT.md
Normal file
98
docs/VAULT.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# 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`
|
||||
4. **Approval** — Admin user reviews and approves the PR
|
||||
5. **Execution** — Dispatcher (issue #76) polls for approved vault PRs and executes them
|
||||
6. **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
|
||||
- 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
|
||||
175
formulas/dev.toml
Normal file
175
formulas/dev.toml
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
# formulas/dev.toml — Dev agent formula (issue implementation)
|
||||
#
|
||||
# Executed by dev/dev-agent.sh via tmux session with Claude.
|
||||
# dev-agent.sh is called by dev-poll.sh which finds the next ready issue
|
||||
# from the backlog (priority tier first, then plain backlog).
|
||||
#
|
||||
# Steps: preflight → implement → CI → review → merge → journal
|
||||
#
|
||||
# Key behaviors:
|
||||
# - Creates worktree for isolation
|
||||
# - Uses tmux session for persistent Claude interaction
|
||||
# - Phase-file signaling for orchestrator coordination
|
||||
# - Auto-retry on CI failures (max 3 attempts)
|
||||
# - Direct-merge for approved PRs (bypasses lock)
|
||||
|
||||
name = "dev"
|
||||
description = "Issue implementation: code, commit, push, address CI/review"
|
||||
version = 1
|
||||
model = "sonnet"
|
||||
|
||||
[context]
|
||||
files = ["AGENTS.md", "dev/AGENTS.md", "lib/env.sh", "lib/pr-lifecycle.sh", "lib/ci-helpers.sh"]
|
||||
|
||||
[[steps]]
|
||||
id = "preflight"
|
||||
title = "Review the issue and prepare implementation plan"
|
||||
description = """
|
||||
Read the issue body carefully. Understand:
|
||||
- What needs to be implemented
|
||||
- Any dependencies (check `## Dependencies` section)
|
||||
- Existing code that might be affected
|
||||
- Testing requirements
|
||||
|
||||
Then create a plan:
|
||||
1. What files need to be modified/created
|
||||
2. What tests need to be added
|
||||
3. Any documentation updates
|
||||
|
||||
Check the preflight metrics from supervisor if available:
|
||||
cat "$OPS_REPO_ROOT/journal/supervisor/$(date -u +%Y-%m-%d).md"
|
||||
|
||||
Note: Only proceed if all dependency issues are closed.
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
id = "implement"
|
||||
title = "Write code to implement the issue"
|
||||
description = """
|
||||
Implement the changes:
|
||||
|
||||
1. Create a new worktree:
|
||||
cd "$PROJECT_REPO_ROOT"
|
||||
git worktree add -b "dev/{agent}-{issue}" ../{agent}-{issue}
|
||||
|
||||
2. Make your changes to the codebase
|
||||
3. Add tests if applicable
|
||||
4. Update documentation if needed
|
||||
5. Commit with conventional commits:
|
||||
git add -A
|
||||
git commit -m "feat({issue}): {description}"
|
||||
|
||||
6. Push to forge:
|
||||
git push -u origin dev/{agent}-{issue}
|
||||
|
||||
7. Create PR via API or web interface
|
||||
- Title: feat({issue}): {description}
|
||||
- Body: Link to issue, describe changes
|
||||
- Labels: backlog, in-progress
|
||||
|
||||
Note: The worktree is preserved on crash for debugging.
|
||||
"""
|
||||
needs = ["preflight"]
|
||||
|
||||
[[steps]]
|
||||
id = "ci"
|
||||
title = "Wait for CI and address failures"
|
||||
description = """
|
||||
Monitor CI pipeline status via Woodpecker API:
|
||||
woodpecker_api /repos/${WOODPECKER_REPO_ID}/pipelines?branch=dev/{agent}-{issue}
|
||||
|
||||
Wait for CI to complete. If CI fails:
|
||||
|
||||
1. Read the CI logs to understand the failure
|
||||
2. Fix the issue
|
||||
3. Amend commit and force push
|
||||
4. Track CI attempts (max 3 retries)
|
||||
|
||||
CI fix tracker file:
|
||||
$DISINTO_LOG_DIR/dev/ci-fixes-{project}.json
|
||||
|
||||
On CI success, proceed to review.
|
||||
If CI exhausted (3 failures), escalate via PHASE:escalate.
|
||||
"""
|
||||
needs = ["implement"]
|
||||
|
||||
[[steps]]
|
||||
id = "review"
|
||||
title = "Address review feedback"
|
||||
description = """
|
||||
Check PR for review comments:
|
||||
curl -sf "${FORGE_API}/pulls/{pr-number}/comments"
|
||||
|
||||
For each comment:
|
||||
1. Understand the feedback
|
||||
2. Make changes to fix the issue
|
||||
3. Amend commit and force push
|
||||
4. Address the comment in the PR
|
||||
|
||||
If review approves, proceed to merge.
|
||||
If stuck or needs clarification, escalate via PHASE:escalate.
|
||||
"""
|
||||
needs = ["ci"]
|
||||
|
||||
[[steps]]
|
||||
id = "merge"
|
||||
title = "Merge the PR"
|
||||
description = """
|
||||
Check if PR is approved and CI is green:
|
||||
curl -sf "${FORGE_API}/pulls/{pr-number}"
|
||||
|
||||
If approved (merged=true or approved_by set):
|
||||
1. Merge the PR:
|
||||
curl -sf -X PUT "${FORGE_API}/pulls/{pr-number}/merge" \\
|
||||
-d '{"merge_method":"merge"}'
|
||||
|
||||
2. Mirror push to other remotes:
|
||||
mirror_push
|
||||
|
||||
3. Close the issue:
|
||||
curl -sf -X PATCH "${FORGE_API}/issues/{issue-number}" \\
|
||||
-d '{"state":"closed"}'
|
||||
|
||||
4. Delete the branch:
|
||||
git push origin --delete dev/{agent}-{issue}
|
||||
|
||||
If direct merge is blocked, note in journal and escalate.
|
||||
"""
|
||||
needs = ["review"]
|
||||
|
||||
[[steps]]
|
||||
id = "journal"
|
||||
title = "Write implementation journal"
|
||||
description = """
|
||||
Append a timestamped entry to the dev journal:
|
||||
|
||||
File path:
|
||||
$OPS_REPO_ROOT/journal/dev/$(date -u +%Y-%m-%d).md
|
||||
|
||||
If the file already exists (multiple PRs merged same day), append.
|
||||
If it does not exist, create it.
|
||||
|
||||
Format:
|
||||
## Dev implementation — {issue-number}
|
||||
Time: {timestamp}
|
||||
PR: {pr-number}
|
||||
Branch: dev/{agent}-{issue}
|
||||
|
||||
### Changes
|
||||
- {summary of changes}
|
||||
|
||||
### CI attempts: {n}
|
||||
### Review feedback: {n} comments addressed
|
||||
|
||||
### Lessons learned
|
||||
- {what you learned during implementation}
|
||||
|
||||
### Knowledge added
|
||||
If you discovered something new, add to knowledge:
|
||||
echo "### Lesson title
|
||||
Description." >> "${OPS_REPO_ROOT}/knowledge/{topic}.md"
|
||||
|
||||
After writing the journal, write the phase signal:
|
||||
echo 'PHASE:done' > "$PHASE_FILE"
|
||||
"""
|
||||
needs = ["merge"]
|
||||
|
|
@ -203,7 +203,7 @@ If all tiers clear, write the completion summary and signal done:
|
|||
echo "ACTION: grooming complete — 0 tech-debt remaining" >> "$RESULT_FILE"
|
||||
echo 'PHASE:done' > "$PHASE_FILE"
|
||||
|
||||
Vault items filed during this run are picked up by vault-poll automatically.
|
||||
Vault items filed during this run appear as PRs on ops repo for human approval.
|
||||
|
||||
On unrecoverable error (API unavailable, repeated failures):
|
||||
printf 'PHASE:failed\nReason: %s\n' 'describe what failed' > "$PHASE_FILE"
|
||||
|
|
|
|||
245
formulas/release.toml
Normal file
245
formulas/release.toml
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
# 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/johba/disinto/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/johba/disinto/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/johba/disinto/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:johba/disinto.git
|
||||
- GitHub: git remote add github git@github.com:disinto/disinto.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/disinto/tags/$RELEASE_VERSION"
|
||||
- curl -sf -H "Authorization: token $FORGE_TOKEN" \
|
||||
- "$FORGE_URL/api/v1/repos/johba/disinto/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/johba/disinto/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
|
||||
"""
|
||||
|
|
@ -112,7 +112,7 @@ near-duplicate exists, REQUEST_CHANGES and reference the existing item.
|
|||
Agents must NEVER execute external actions directly. Any action that touches
|
||||
an external system (publish, deploy, post, push to external registry, API
|
||||
calls to third-party services) MUST go through vault dispatch — i.e., the
|
||||
agent files a vault item (`$OPS_REPO_ROOT/vault/pending/*.json`) and the vault-runner
|
||||
agent files a vault item (`$OPS_REPO_ROOT/vault/pending/*.json`) and the runner
|
||||
container executes it with injected secrets.
|
||||
|
||||
Scan the diff for these patterns:
|
||||
|
|
@ -128,8 +128,7 @@ Scan the diff for these patterns:
|
|||
|
||||
If ANY of these patterns appear in agent code (scripts in `dev/`, `action/`,
|
||||
`planner/`, `gardener/`, `supervisor/`, `predictor/`, `review/`, `formulas/`,
|
||||
`lib/`) WITHOUT routing through vault dispatch (`$OPS_REPO_ROOT/vault/pending/`, `vault-fire.sh`,
|
||||
`vault-run-action.sh`), **REQUEST_CHANGES**.
|
||||
`lib/`) WITHOUT routing through vault dispatch (file a vault PR on ops repo — see #73-#77), **REQUEST_CHANGES**.
|
||||
|
||||
Explain that external actions must use vault dispatch per AD-006. The agent
|
||||
should file a vault item instead of executing directly.
|
||||
|
|
@ -137,7 +136,7 @@ should file a vault item instead of executing directly.
|
|||
**Exceptions** (do NOT flag these):
|
||||
- Code inside `vault/` — the vault system itself is allowed to handle secrets
|
||||
- References in comments or documentation explaining the architecture
|
||||
- `bin/disinto` setup commands that manage `.env.vault.enc`
|
||||
- `bin/disinto` setup commands that manage `.env.vault.enc` and the `run` subcommand
|
||||
- Local operations (git push to forge, forge API calls with `FORGE_TOKEN`)
|
||||
|
||||
## 6. Re-review (if previous review is provided)
|
||||
|
|
|
|||
304
formulas/run-architect.toml
Normal file
304
formulas/run-architect.toml
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
# formulas/run-architect.toml — Architect formula
|
||||
#
|
||||
# Executed by architect-run.sh via cron — strategic decomposition of vision
|
||||
# issues into development sprints.
|
||||
#
|
||||
# This formula orchestrates the architect agent's workflow:
|
||||
# Step 1: Preflight — validate prerequisites and identify target issue
|
||||
# Step 2: Research + pitch — analyze codebase and write sprint pitch
|
||||
# Step 3: Sprint PR creation with questions (issue #101)
|
||||
# Step 4: Answer parsing + sub-issue filing (issue #102)
|
||||
#
|
||||
# AGENTS.md maintenance is handled by the gardener (#246).
|
||||
|
||||
name = "run-architect"
|
||||
description = "Architect: strategic decomposition of vision into sprints"
|
||||
version = 1
|
||||
model = "opus"
|
||||
|
||||
[context]
|
||||
files = ["VISION.md", "AGENTS.md"]
|
||||
# Prerequisite tree loaded from ops repo (ops: prefix)
|
||||
# Sprints directory tracked in ops repo
|
||||
|
||||
[[steps]]
|
||||
id = "preflight"
|
||||
title = "Preflight: validate prerequisites and identify target vision issue"
|
||||
description = """
|
||||
This step performs preflight checks and identifies the most unblocking vision issue.
|
||||
|
||||
Actions:
|
||||
1. Pull latest code from both disinto repo and ops repo
|
||||
2. Read prerequisite tree from $OPS_REPO_ROOT/prerequisites.md
|
||||
3. Fetch open issues labeled 'vision' from Forgejo API
|
||||
4. Check for open architect PRs on ops repo (handled by #101/#102)
|
||||
5. If open architect PRs exist, handle accept/reject responses (see Capability B below)
|
||||
6. If no vision issues, signal PHASE:done
|
||||
|
||||
Skip conditions:
|
||||
- If no vision issues are found, signal PHASE:done
|
||||
|
||||
Output:
|
||||
- Sets ARCHITECT_TARGET_ISSUE to the issue number of the selected vision issue
|
||||
- Exports VISION_ISSUES as a JSON array of issue objects
|
||||
|
||||
## Capability B: Handle accept/reject on existing pitch PRs
|
||||
|
||||
When open architect PRs exist on the ops repo:
|
||||
|
||||
1. Fetch comments on each open architect PR via Forgejo API
|
||||
2. Look for human response:
|
||||
|
||||
**ACCEPT** (case insensitive): Human wants to proceed
|
||||
- Architect does deep research for design forks (same as #100 research but now identifying decision points)
|
||||
- Formulates multiple-choice questions (Q1, Q2, Q3...)
|
||||
- Updates the sprint spec file on the PR branch:
|
||||
- Adds `## Design forks` section with fork options
|
||||
- Adds `## Proposed sub-issues` section with concrete issues per fork path
|
||||
- Comments on the PR with the questions formatted as multiple choice
|
||||
- Signal PHASE:done (answer processing is #102)
|
||||
|
||||
**REJECT: <reason>** (case insensitive, reason after colon):
|
||||
- Journal the rejection reason via profile_write_journal (if .profile exists)
|
||||
— the architect learns what pitches fail
|
||||
- Close the PR via Forgejo API (do not merge — rejected pitches do not persist in sprints/)
|
||||
- Remove the branch via Forgejo API
|
||||
- Signal PHASE:done
|
||||
|
||||
**No response yet**: skip silently, signal PHASE:done
|
||||
|
||||
All git operations use the Forgejo API (create branch, write/update file, create PR,
|
||||
close PR, delete branch). No SSH.
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
id = "research_pitch"
|
||||
title = "Research + pitch: analyze codebase and write sprint pitch"
|
||||
description = """
|
||||
This step performs deep codebase research and writes a sprint pitch for the
|
||||
selected vision issue.
|
||||
|
||||
Actions:
|
||||
|
||||
1. Read the codebase deeply:
|
||||
- Read all files mentioned in the issue body
|
||||
- Search for existing interfaces that could be reused
|
||||
- Check what infrastructure already exists
|
||||
|
||||
2. Assess complexity and cost:
|
||||
- How many files/subsystems are touched?
|
||||
- What new infrastructure would need to be maintained after this sprint?
|
||||
- What are the risks (breaking changes, security implications, integration complexity)?
|
||||
- Is this mostly gluecode or greenfield?
|
||||
|
||||
3. Write sprint pitch to scratch file for PR creation step (#101):
|
||||
|
||||
# Sprint pitch: <name>
|
||||
|
||||
## Vision issues
|
||||
- #N — <title>
|
||||
|
||||
## What this enables
|
||||
<what the project can do after this sprint that it can't do now>
|
||||
|
||||
## What exists today
|
||||
<current state — infrastructure, interfaces, code that can be reused>
|
||||
|
||||
## Complexity
|
||||
<number of files, subsystems, estimated sub-issues>
|
||||
<gluecode vs greenfield ratio>
|
||||
|
||||
## Risks
|
||||
<what could go wrong, what breaks if this is done badly>
|
||||
|
||||
## Cost — new infra to maintain
|
||||
<what ongoing maintenance burden does this sprint add>
|
||||
<new services, cron jobs, formulas, agent roles>
|
||||
|
||||
## Recommendation
|
||||
<architect's assessment: worth it / defer / alternative approach>
|
||||
|
||||
IMPORTANT: Do NOT include design forks or questions yet. The pitch is a go/no-go
|
||||
decision for the human. Questions come only after acceptance.
|
||||
|
||||
Output:
|
||||
- Writes sprint pitch to $SCRATCH_FILE (/tmp/architect-{project}-scratch.md)
|
||||
- The pitch serves as input for sprint PR creation step (#101)
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
id = "sprint_pr_creation"
|
||||
title = "Sprint PR creation with questions (issue #101)"
|
||||
description = """
|
||||
This step creates a PR on the ops repo with the sprint proposal when no PR exists yet.
|
||||
|
||||
## Capability A: Create pitch PR (from research output)
|
||||
|
||||
If step 2 (research/pitch) produced a pitch and no PR exists yet:
|
||||
|
||||
1. Create branch `architect/<sprint-slug>` on ops repo via Forgejo API
|
||||
- Sprint slug: lowercase, hyphenated version of sprint name
|
||||
- Use Forgejo API: POST /repos/{owner}/{repo}/git/branches
|
||||
|
||||
2. Write sprint spec file to sprints/<sprint-slug>.md on the new branch:
|
||||
|
||||
# Sprint: <name>
|
||||
|
||||
## Vision issues
|
||||
- #N — <title>
|
||||
|
||||
## What this enables
|
||||
<what the project can do after this sprint that it can't do now>
|
||||
|
||||
## What exists today
|
||||
<current state — infrastructure, interfaces, code that can be reused>
|
||||
|
||||
## Complexity
|
||||
<number of files/subsystems, estimated sub-issues>
|
||||
<gluecode vs greenfield ratio>
|
||||
|
||||
## Risks
|
||||
<what could go wrong, what breaks if this is done badly>
|
||||
|
||||
## Cost — new infra to maintain
|
||||
<what ongoing maintenance burden does this sprint add>
|
||||
<new services, cron jobs, formulas, agent roles>
|
||||
|
||||
## Recommendation
|
||||
<architect's assessment: worth it / defer / alternative approach>
|
||||
|
||||
3. Create PR on ops repo via Forgejo API:
|
||||
- Title: `architect: <sprint summary>`
|
||||
- Body: pitch content (what it enables, complexity, risks, cost)
|
||||
- Base branch: primary branch (main/master)
|
||||
- Head branch: architect/<sprint-slug>
|
||||
- Footer: "Reply `ACCEPT` to proceed with design questions, or `REJECT: <reason>` to decline."
|
||||
|
||||
4. Signal PHASE:done
|
||||
|
||||
## Forgejo API Reference
|
||||
|
||||
All operations use the Forgejo API with `Authorization: token ${FORGE_TOKEN}` header.
|
||||
|
||||
### Create branch
|
||||
```
|
||||
POST /repos/{owner}/{repo}/branches
|
||||
Body: {"new_branch_name": "architect/<sprint-slug>", "old_branch_name": "main"}
|
||||
```
|
||||
|
||||
### Create/update file
|
||||
```
|
||||
PUT /repos/{owner}/{repo}/contents/<path>
|
||||
Body: {"message": "sprint: add <sprint-slug>.md", "content": "<base64-encoded-content>", "branch": "architect/<sprint-slug>"}
|
||||
```
|
||||
|
||||
### Create PR
|
||||
```
|
||||
POST /repos/{owner}/{repo}/pulls
|
||||
Body: {"title": "architect: <sprint summary>", "body": "<pitch-content>", "head": "architect/<sprint-slug>", "base": "main"}
|
||||
```
|
||||
|
||||
### Close PR
|
||||
```
|
||||
PATCH /repos/{owner}/{repo}/pulls/{index}
|
||||
Body: {"state": "closed"}
|
||||
```
|
||||
|
||||
### Delete branch
|
||||
```
|
||||
DELETE /repos/{owner}/{repo}/git/branches/<branch-name>
|
||||
```
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
id = "answer_parsing"
|
||||
title = "Answer parsing + sub-issue filing (issue #102)"
|
||||
description = """
|
||||
This step processes human answers to design questions and files sub-issues.
|
||||
|
||||
## Preflight: Detect PRs in question phase
|
||||
|
||||
An architect PR is in the question phase if ALL of the following are true:
|
||||
- PR is open
|
||||
- PR body or sprint spec file contains a `## Design forks` section (added by #101 after ACCEPT)
|
||||
- PR has question comments (Q1, Q2, Q3... format)
|
||||
|
||||
## Answer parsing
|
||||
|
||||
Human comments on the PR use this format:
|
||||
```
|
||||
Q1: A
|
||||
Q2: B
|
||||
Q3: A
|
||||
```
|
||||
|
||||
Parser matches lines starting with `Q` + digit(s) + `:` + space + letter A-D (case insensitive).
|
||||
Ignore other content in the comment.
|
||||
|
||||
## Processing paths
|
||||
|
||||
### All questions answered (every `### Q` heading has a matching `Q<N>: <letter>` comment)
|
||||
|
||||
1. Parse each answer (e.g. `Q1: A`, `Q2: C`)
|
||||
2. Read the sprint spec from the PR branch
|
||||
3. Generate final sub-issues based on answers:
|
||||
- Each sub-issue uses the appropriate issue template (bug/feature/refactor from `.codeberg/ISSUE_TEMPLATE/`)
|
||||
- Fill all template fields:
|
||||
- Problem/motivation (feature) or What's broken (bug/refactor)
|
||||
- Proposed solution (feature) or Approach (refactor) or Steps to reproduce (bug)
|
||||
- Affected files (max 3)
|
||||
- Acceptance criteria (max 5)
|
||||
- Dependencies
|
||||
- File via Forgejo API on the **disinto repo** (not ops repo)
|
||||
- Label as `backlog`
|
||||
4. Comment on PR: "Sprint filed: #N, #N, #N"
|
||||
5. Merge the PR (sprint spec with answers persists in `ops/sprints/`)
|
||||
|
||||
### Some questions answered, not all
|
||||
|
||||
1. Acknowledge answers received
|
||||
2. Comment listing remaining unanswered questions
|
||||
3. Signal PHASE:done (check again next poll)
|
||||
|
||||
### No answers yet (questions posted but human hasn't responded)
|
||||
|
||||
1. Skip — signal PHASE:done
|
||||
|
||||
## Forgejo API for filing issues on disinto repo
|
||||
|
||||
All operations use the Forgejo API with `Authorization: token ${FORGE_TOKEN}` header.
|
||||
|
||||
### Create issue
|
||||
```
|
||||
POST /repos/{owner}/{repo}/issues
|
||||
Body: {
|
||||
"title": "<issue title>",
|
||||
"body": "<issue body with template fields>",
|
||||
"labels": [123], // backlog label ID
|
||||
"assignees": ["architect-bot"]
|
||||
}
|
||||
```
|
||||
|
||||
### Close PR
|
||||
```
|
||||
PATCH /repos/{owner}/{repo}/pulls/{index}
|
||||
Body: {"state": "closed"}
|
||||
```
|
||||
|
||||
### Merge PR
|
||||
```
|
||||
POST /repos/{owner}/{repo}/pulls/{index}/merge
|
||||
Body: {"Do": "merge"}
|
||||
```
|
||||
|
||||
### Post comment on PR (via issues endpoint)
|
||||
```
|
||||
POST /repos/{owner}/{repo}/issues/{index}/comments
|
||||
Body: {"body": "<comment text>"}
|
||||
```
|
||||
|
||||
### Get label ID
|
||||
```
|
||||
GET /repos/{owner}/{repo}/labels
|
||||
```
|
||||
"""
|
||||
|
|
@ -1,16 +1,15 @@
|
|||
# formulas/run-gardener.toml — Gardener housekeeping formula
|
||||
#
|
||||
# Defines the gardener's complete run: grooming (Claude session via
|
||||
# gardener-run.sh) + blocked-review + AGENTS.md maintenance + final
|
||||
# commit-and-pr.
|
||||
# gardener-run.sh) + AGENTS.md maintenance + final commit-and-pr.
|
||||
#
|
||||
# No memory, no journal. The gardener does mechanical housekeeping
|
||||
# based on current state — it doesn't need to remember past runs.
|
||||
# Gardener has journaling via .profile (issue #97), so it learns from
|
||||
# past runs and improves over time.
|
||||
#
|
||||
# Steps: preflight → grooming → dust-bundling → blocked-review → stale-pr-recycle → agents-update → commit-and-pr
|
||||
# Steps: preflight -> grooming -> dust-bundling -> agents-update -> commit-and-pr
|
||||
|
||||
name = "run-gardener"
|
||||
description = "Mechanical housekeeping: grooming, blocked review, docs update"
|
||||
description = "Mechanical housekeeping: grooming, dust bundling, docs update"
|
||||
version = 1
|
||||
|
||||
[context]
|
||||
|
|
@ -120,15 +119,17 @@ DUST (trivial — single-line edit, rename, comment, style, whitespace):
|
|||
of 3+ into one backlog issue.
|
||||
|
||||
VAULT (needs human decision or external resource):
|
||||
File a vault procurement item at $OPS_REPO_ROOT/vault/pending/<id>.md:
|
||||
# <What decision or resource is needed>
|
||||
## What
|
||||
<description>
|
||||
## Why
|
||||
<which issue this unblocks>
|
||||
## Unblocks
|
||||
- #NNN — <title>
|
||||
Log: echo "VAULT: filed $OPS_REPO_ROOT/vault/pending/<id>.md for #NNN — <reason>" >> "$RESULT_FILE"
|
||||
File a vault procurement item using vault_request():
|
||||
source "$(dirname "$0")/../lib/vault.sh"
|
||||
TOML_CONTENT="# Vault action: <action_id>
|
||||
context = \"<description of what decision/resource is needed>\"
|
||||
unblocks = [\"#NNN\"]
|
||||
|
||||
[execution]
|
||||
# Commands to run after approval
|
||||
"
|
||||
PR_NUM=$(vault_request "<action_id>" "$TOML_CONTENT")
|
||||
echo "VAULT: filed PR #${PR_NUM} for #NNN — <reason>" >> "$RESULT_FILE"
|
||||
|
||||
CLEAN (only if truly nothing to do):
|
||||
echo 'CLEAN' >> "$RESULT_FILE"
|
||||
|
|
@ -142,25 +143,7 @@ Sibling dependency rule (CRITICAL):
|
|||
NEVER add bidirectional ## Dependencies between siblings (creates deadlocks).
|
||||
Use ## Related for cross-references: "## Related\n- #NNN (sibling)"
|
||||
|
||||
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:
|
||||
6. Quality gate — backlog label enforcement:
|
||||
For each open issue labeled 'backlog', verify it has the required
|
||||
sections for dev-agent pickup:
|
||||
a. Acceptance criteria — body must contain at least one checkbox
|
||||
|
|
@ -181,28 +164,11 @@ Sibling dependency rule (CRITICAL):
|
|||
Well-structured issues (both sections present) are left untouched —
|
||||
they are ready for dev-agent pickup.
|
||||
|
||||
9. Portfolio lifecycle — maintain ## Addressables and ## Observables in AGENTS.md:
|
||||
Read the current Addressables and Observables tables from AGENTS.md.
|
||||
|
||||
a. ADD: if a recently closed issue shipped a new deployment, listing,
|
||||
package, or external presence not yet in the table, add a row.
|
||||
b. PROMOTE: if an addressable now has measurement wired (an evidence
|
||||
process reads from it), move it to the Observables section.
|
||||
c. REMOVE: if an addressable was decommissioned (vision change
|
||||
invalidated it, service shut down), remove the row and log why.
|
||||
d. FLAG: if an addressable has been live > 2 weeks with Observable? = No
|
||||
and no evidence process is planned, add a comment to the result file:
|
||||
echo "ACTION: flagged addressable '<name>' — live >2 weeks, no observation path" >> "$RESULT_FILE"
|
||||
|
||||
Stage AGENTS.md if changed — the commit-and-pr step handles the actual commit.
|
||||
|
||||
Processing order:
|
||||
1. Handle PRIORITY_blockers_starving_factory first — promote or resolve
|
||||
2. AD alignment check — close backlog issues that violate architecture decisions
|
||||
3. Quality gate — strip backlog from issues missing acceptance criteria or affected files
|
||||
4. Process tech-debt issues by score (impact/effort)
|
||||
5. Classify remaining items as dust or route to vault
|
||||
6. Portfolio lifecycle — update addressables/observables tables
|
||||
2. Quality gate — strip backlog from issues missing acceptance criteria or affected files
|
||||
3. Process tech-debt issues by score (impact/effort)
|
||||
4. Classify remaining items as dust or route to vault
|
||||
|
||||
Do NOT bundle dust yourself — the dust-bundling step handles accumulation,
|
||||
dedup, TTL expiry, and bundling into backlog issues.
|
||||
|
|
@ -257,126 +223,12 @@ session, so changes there would be lost.
|
|||
|
||||
5. If no DUST items were emitted and no groups are ripe, skip this step.
|
||||
|
||||
CRITICAL: If this step fails, log the failure and move on to blocked-review.
|
||||
CRITICAL: If this step fails, log the failure and move on.
|
||||
"""
|
||||
needs = ["grooming"]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
# Step 4: agents-update — AGENTS.md watermark staleness + size enforcement
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[steps]]
|
||||
|
|
@ -497,10 +349,10 @@ needed. You wouldn't dump a 500-page wiki on a new hire's first morning.
|
|||
CRITICAL: If this step fails for any reason, log the failure and move on.
|
||||
Do NOT let an AGENTS.md failure prevent the commit-and-pr step.
|
||||
"""
|
||||
needs = ["stale-pr-recycle"]
|
||||
needs = ["dust-bundling"]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Step 7: commit-and-pr — single commit with all file changes
|
||||
# Step 5: commit-and-pr — single commit with all file changes
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[steps]]
|
||||
|
|
@ -554,16 +406,14 @@ executes them after the PR merges.
|
|||
PR_NUMBER=$(echo "$PR_RESPONSE" | jq -r '.number')
|
||||
h. Save PR number for orchestrator tracking:
|
||||
echo "$PR_NUMBER" > /tmp/gardener-pr-${PROJECT_NAME}.txt
|
||||
i. Signal the orchestrator to monitor CI:
|
||||
echo "PHASE:awaiting_ci" > "$PHASE_FILE"
|
||||
j. STOP and WAIT. Do NOT return to the primary branch.
|
||||
The orchestrator polls CI, injects results and review feedback.
|
||||
When you receive injected CI or review feedback, follow its
|
||||
instructions, then write PHASE:awaiting_ci and wait again.
|
||||
i. The orchestrator handles CI/review via pr_walk_to_merge.
|
||||
The gardener stays alive to inject CI results and review feedback
|
||||
as they come in, then executes the pending-actions manifest after merge.
|
||||
|
||||
4. If no file changes existed (step 2 found nothing):
|
||||
echo "PHASE:done" > "$PHASE_FILE"
|
||||
# Nothing to commit — the gardener has no work to do this run.
|
||||
exit 0
|
||||
|
||||
5. If PR creation fails, log the error and write PHASE:failed.
|
||||
5. If PR creation fails, log the error and exit.
|
||||
"""
|
||||
needs = ["agents-update"]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
# planner-run.sh creates a tmux session with Claude (opus) and injects
|
||||
# this formula as context, plus the graph report from build-graph.py.
|
||||
#
|
||||
# Steps: preflight → triage-and-plan → journal-and-commit
|
||||
# Steps: preflight → triage-and-plan → commit-ops-changes
|
||||
#
|
||||
# v4 changes from v3:
|
||||
# - Graph report (orphans, cycles, thin objectives, bottlenecks) replaces
|
||||
|
|
@ -13,7 +13,8 @@
|
|||
# - 3 steps instead of 6.
|
||||
#
|
||||
# AGENTS.md maintenance is handled by the gardener (#246).
|
||||
# All git writes (tree, journal, memory) happen in one commit at the end.
|
||||
# All git writes (tree, memory) happen in one commit at the end.
|
||||
# Journal writing is delegated to generic profile_write_journal() function.
|
||||
|
||||
name = "run-planner"
|
||||
description = "Planner v4: graph-driven planning with tea helpers"
|
||||
|
|
@ -151,13 +152,10 @@ From the updated tree + graph bottlenecks, identify the top 5 constraints.
|
|||
A constraint is an unresolved prerequisite blocking the most downstream objectives.
|
||||
Graph bottlenecks (high betweenness centrality) and thin objectives inform ranking.
|
||||
|
||||
Stuck issue handling:
|
||||
- BOUNCED/LABEL_CHURN: do NOT re-promote. Dispatch groom-backlog formula instead:
|
||||
tea_file_issue "chore: break down #<N> — bounced <count>x" "<body>" "action"
|
||||
- HUMAN_BLOCKED (needs human decision or external resource): file a vault
|
||||
procurement item instead of skipping. First check for duplicates across ALL
|
||||
vault directories (pending/, approved/, fired/) — if a file with the same
|
||||
slug already exists in any of them, do NOT create a new one.
|
||||
HUMAN_BLOCKED handling (needs human decision or external resource):
|
||||
- File a vault procurement item instead of skipping. First check for duplicates
|
||||
across ALL vault directories (pending/, approved/, fired/) — if a file with the
|
||||
same slug already exists in any of them, do NOT create a new one.
|
||||
Naming: $OPS_REPO_ROOT/vault/pending/<project>-<slug>.md (e.g. disinto-github-org.md).
|
||||
Write with this template:
|
||||
|
||||
|
|
@ -185,10 +183,37 @@ Stuck issue handling:
|
|||
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.
|
||||
|
||||
Filing gate (for non-stuck constraints):
|
||||
1. Check if issue already exists (match by #number in tree or title search)
|
||||
2. If no issue, create one with tea_file_issue using the template above
|
||||
3. If issue exists and is open, skip — no duplicates
|
||||
Template-or-vision filing gate (for non-stuck constraints):
|
||||
1. Read issue templates from .codeberg/ISSUE_TEMPLATE/*.yaml:
|
||||
- bug.yaml: for broken/incorrect behavior (error in logs, failing test)
|
||||
- feature.yaml: for new capabilities (prerequisite doesn't exist)
|
||||
- refactor.yaml: for restructuring without behavior change
|
||||
|
||||
2. Attempt to fill template fields:
|
||||
- affected_files: list 3 or fewer specific files
|
||||
- acceptance_criteria: write concrete, checkable criteria (max 5)
|
||||
- proposed_solution/approach: is there one clear approach, or design forks?
|
||||
|
||||
3. Complexity test:
|
||||
- If work touches ONE subsystem (3 or fewer files) AND no design forks
|
||||
(only one reasonable approach) AND template fields fill confidently:
|
||||
→ File as `backlog` using matching template format
|
||||
- Otherwise → Label `vision` with short body:
|
||||
- Problem statement
|
||||
- Why it's vision-sized
|
||||
- Which objectives it blocks
|
||||
- Include "## Why vision" section explaining complexity
|
||||
|
||||
4. Template selection heuristic:
|
||||
- Bug template: planner identifies something broken (error in logs,
|
||||
incorrect behavior, failing test)
|
||||
- Feature template: new capability needed (prerequisite doesn't exist)
|
||||
- Refactor template: existing code needs restructuring without behavior change
|
||||
|
||||
5. Filing steps:
|
||||
- Check if issue already exists (match by #number in tree or title search)
|
||||
- If no issue, create with tea_file_issue using template format
|
||||
- If issue exists and is open, skip — no duplicates
|
||||
|
||||
Priority label sync:
|
||||
- Add priority to current top-5 constraint issues (if missing):
|
||||
|
|
@ -217,50 +242,13 @@ CRITICAL: If any part of this step fails, log the failure and continue.
|
|||
needs = ["preflight"]
|
||||
|
||||
[[steps]]
|
||||
id = "journal-and-commit"
|
||||
title = "Write tree, journal, optional memory; commit and PR"
|
||||
id = "commit-ops-changes"
|
||||
title = "Write tree, memory, and journal; commit and push"
|
||||
description = """
|
||||
### 1. Write prerequisite tree
|
||||
Write to: $OPS_REPO_ROOT/prerequisites.md
|
||||
|
||||
### 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)
|
||||
### 2. Memory update (every 5th run)
|
||||
Count "# Planner run —" headers across all journal files.
|
||||
Check "<!-- summarized-through-run: N -->" in planner-memory.md.
|
||||
If (count - N) >= 5 or planner-memory.md missing, write to:
|
||||
|
|
@ -268,15 +256,19 @@ If (count - N) >= 5 or planner-memory.md missing, write to:
|
|||
Include: run counter marker, date, constraint focus, patterns, direction.
|
||||
Keep under 100 lines. Replace entire file.
|
||||
|
||||
### 4. Commit ops repo changes
|
||||
Commit the ops repo changes (prerequisites, journal, memory, vault items):
|
||||
### 3. Commit ops repo changes
|
||||
Commit the ops repo changes (prerequisites, memory, vault items):
|
||||
cd "$OPS_REPO_ROOT"
|
||||
git add prerequisites.md journal/planner/ knowledge/planner-memory.md vault/pending/
|
||||
git add prerequisites.md knowledge/planner-memory.md vault/pending/
|
||||
git add -u
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "chore: planner run $(date -u +%Y-%m-%d)"
|
||||
git push origin "$PRIMARY_BRANCH"
|
||||
fi
|
||||
cd "$PROJECT_REPO_ROOT"
|
||||
|
||||
### 4. Write journal entry (generic)
|
||||
The planner-run.sh wrapper will handle journal writing via profile_write_journal()
|
||||
after the formula completes. This step is informational only.
|
||||
"""
|
||||
needs = ["triage-and-plan"]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
# Trigger: action issue created by planner (gap analysis), dev-poll (post-merge
|
||||
# hook detecting site/ changes), or gardener (periodic SHA drift check).
|
||||
#
|
||||
# The action-agent picks up the issue, executes these steps, posts results
|
||||
# The dispatcher picks up the issue, executes these steps, posts results
|
||||
# as a comment, and closes the issue.
|
||||
|
||||
name = "run-publish-site"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
# the action and notifies the human for one-click copy-paste execution.
|
||||
#
|
||||
# Trigger: action issue created by planner or any formula.
|
||||
# The action-agent picks up the issue, executes these steps, writes a draft
|
||||
# The dispatcher picks up the issue, executes these steps, writes a draft
|
||||
# to vault/outreach/{platform}/drafts/, notifies the human via the forge,
|
||||
# and closes the issue.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ human judgment, file a vault procurement item:
|
|||
<impact on factory health — reference the priority level>
|
||||
## Unblocks
|
||||
- Factory health: <what this resolves>
|
||||
The vault-poll will notify the human and track the request.
|
||||
Vault PR filed on ops repo — human approves via PR review.
|
||||
|
||||
Read the relevant best-practices file before taking action:
|
||||
cat "$OPS_REPO_ROOT/knowledge/memory.md" # P0
|
||||
|
|
@ -241,6 +241,16 @@ run-to-run context so future supervisor runs can detect trends
|
|||
IMPORTANT: Do NOT commit or push the journal — it is a local working file.
|
||||
The journal directory is committed to git periodically by other agents.
|
||||
|
||||
## Learning
|
||||
|
||||
If you discover something new during this run, append it to the relevant
|
||||
knowledge file in the ops repo:
|
||||
echo "### Lesson title
|
||||
Description of what you learned." >> "${OPS_REPO_ROOT}/knowledge/<file>.md"
|
||||
|
||||
Knowledge files: memory.md, disk.md, ci.md, forge.md, dev-agent.md,
|
||||
review-agent.md, git.md.
|
||||
|
||||
After writing the journal, write the phase signal:
|
||||
echo 'PHASE:done' > "$PHASE_FILE"
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ directly from cron like the planner, predictor, and supervisor.
|
|||
`PHASE:awaiting_ci` — injects CI results and review feedback, re-signals
|
||||
`PHASE:awaiting_ci` after fixes, signals `PHASE:awaiting_review` on CI pass.
|
||||
Executes pending-actions manifest after PR merge.
|
||||
- `formulas/run-gardener.toml` — Execution spec: preflight, grooming, dust-bundling, blocked-review, agents-update, commit-and-pr
|
||||
- `formulas/run-gardener.toml` — Execution spec: preflight, grooming, dust-bundling,
|
||||
agents-update, commit-and-pr
|
||||
- `gardener/pending-actions.json` — Manifest of deferred repo actions (label changes,
|
||||
closures, comments, issue creation). Written during grooming steps, committed to the
|
||||
PR, reviewed alongside AGENTS.md changes, executed by gardener-run.sh after merge.
|
||||
|
|
@ -34,7 +35,7 @@ directly from cron like the planner, predictor, and supervisor.
|
|||
**Lifecycle**: gardener-run.sh (cron 0,6,12,18) → `check_active gardener` → lock + memory guard →
|
||||
load formula + context → create tmux session →
|
||||
Claude grooms backlog (writes proposed actions to manifest), bundles dust,
|
||||
reviews blocked issues, updates AGENTS.md, commits manifest + docs to PR →
|
||||
updates AGENTS.md, commits manifest + docs to PR →
|
||||
`PHASE:awaiting_ci` (stays alive) → CI pass → `PHASE:awaiting_review` →
|
||||
review feedback → address + re-signal → merge → gardener-run.sh executes
|
||||
manifest actions via API → `PHASE:done`. When blocked on external resources
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -64,10 +64,19 @@ check_memory 2000
|
|||
|
||||
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 "$FACTORY_ROOT/formulas/run-gardener.toml"
|
||||
load_formula_or_profile "gardener" "$FACTORY_ROOT/formulas/run-gardener.toml" || exit 1
|
||||
build_context_block AGENTS.md
|
||||
|
||||
# ── Prepare .profile context (lessons injection) ─────────────────────────
|
||||
formula_prepare_profile_context
|
||||
|
||||
# ── Read scratch file (compaction survival) ───────────────────────────────
|
||||
SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
|
||||
SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
|
||||
|
|
@ -105,7 +114,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.
|
||||
|
||||
## Project context
|
||||
${CONTEXT_BLOCK}
|
||||
${CONTEXT_BLOCK}$(formula_lessons_block)
|
||||
${SCRATCH_CONTEXT:+${SCRATCH_CONTEXT}
|
||||
}
|
||||
## Result file
|
||||
|
|
@ -334,5 +343,8 @@ else
|
|||
rm -f "$SCRATCH_FILE"
|
||||
fi
|
||||
|
||||
# Write journal entry post-session
|
||||
profile_write_journal "gardener-run" "Gardener run $(date -u +%Y-%m-%d)" "complete" "" || true
|
||||
|
||||
rm -f "$GARDENER_PR_FILE"
|
||||
log "--- Gardener run done ---"
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
# 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)"
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
# 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"
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
# 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"
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
# 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"
|
||||
|
|
@ -6,19 +6,20 @@ sourced as needed.
|
|||
|
||||
| File | What it provides | Sourced by |
|
||||
|---|---|---|
|
||||
| `lib/env.sh` | Loads `.env`, sets `FACTORY_ROOT`, exports project config (`FORGE_REPO`, `PROJECT_NAME`, etc.), defines `log()`, `forge_api()`, `forge_api_all()` (accepts optional second TOKEN parameter, defaults to `$FORGE_TOKEN`), `woodpecker_api()`, `wpdb()`, `memory_guard()` (skips agent if RAM < threshold). Auto-loads project TOML if `PROJECT_TOML` is set. Exports per-agent tokens (`FORGE_PLANNER_TOKEN`, `FORGE_GARDENER_TOKEN`, `FORGE_VAULT_TOKEN`, `FORGE_SUPERVISOR_TOKEN`, `FORGE_PREDICTOR_TOKEN`, `FORGE_ACTION_TOKEN`) — each falls back to `$FORGE_TOKEN` if not set. **Vault-only token guard (AD-006)**: `unset GITHUB_TOKEN CLAWHUB_TOKEN` so agents never hold external-action tokens — only the vault-runner container receives them. **Container note**: when `DISINTO_CONTAINER=1`, `.env` is NOT re-sourced — compose already injects env vars (including `FORGE_URL=http://forgejo:3000`) and re-sourcing would clobber them. | Every agent |
|
||||
| `lib/ci-helpers.sh` | `ci_passed()` — returns 0 if CI state is "success" (or no CI configured). `ci_required_for_pr()` — returns 0 if PR has code files (CI required), 1 if non-code only (CI not required). `is_infra_step()` — returns 0 if a single CI step failure matches infra heuristics (clone/git exit 128, any exit 137, log timeout patterns). `classify_pipeline_failure()` — returns "infra \<reason>" if any failed Woodpecker step matches infra heuristics via `is_infra_step()`, else "code". `ensure_priority_label()` — looks up (or creates) the `priority` label and returns its ID; caches in `_PRIORITY_LABEL_ID`. `ci_commit_status <sha>` — queries Woodpecker directly for CI state, falls back to forge commit status API. `ci_pipeline_number <sha>` — returns the Woodpecker pipeline number for a commit, falls back to parsing forge status `target_url`. `ci_promote <repo_id> <pipeline_num> <environment>` — promotes a pipeline to a named Woodpecker environment (vault-gated deployment: vault approves, vault-fire calls this). | dev-poll, review-poll, review-pr, supervisor-poll |
|
||||
| `lib/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`) — 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/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-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.). | env.sh (when `PROJECT_TOML` is set), supervisor-poll (per-project iteration) |
|
||||
| `lib/parse-deps.sh` | Extracts dependency issue numbers from an issue body (stdin → stdout, one number per line). Matches `## Dependencies` / `## Depends on` / `## Blocked by` sections and inline `depends on #N` / `blocked by #N` patterns. Inline scan skips fenced code blocks to prevent false positives from code examples in issue bodies. Not sourced — executed via `bash lib/parse-deps.sh`. | dev-poll, supervisor-poll |
|
||||
| `lib/formula-session.sh` | `acquire_cron_lock()`, `check_memory()`, `load_formula()`, `build_context_block()`, `consume_escalation_reply()`, `start_formula_session()`, `formula_phase_callback()`, `build_prompt_footer()`, `build_graph_section()`, `run_formula_and_monitor(AGENT [TIMEOUT] [CALLBACK])` — shared helpers for formula-driven cron agents (lock, memory guard, formula loading, prompt assembly, tmux session, monitor loop, crash recovery). `build_graph_section()` generates the structural-analysis section (runs `lib/build-graph.py`, formats JSON output) — previously duplicated in planner-run.sh and predictor-run.sh, now shared here. `formula_phase_callback()` handles `PHASE:escalate` (unified escalation path — kills the session). `run_formula_and_monitor` accepts an optional CALLBACK (default: `formula_phase_callback`) so callers can install custom merge-through or escalation handlers. `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, action-poll.sh, predictor-run.sh, supervisor-run.sh. | cron entry points |
|
||||
| `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/guard.sh` | `check_active(agent_name)` — reads `$FACTORY_ROOT/state/.{agent_name}-active`; exits 0 (skip) if the file is absent. Factory is off by default — state files must be created to enable each agent. **Logs a message to stderr** when skipping (`[check_active] SKIP: state file not found`), so agent dropout is visible in cron logs. Sourced by dev-poll.sh, review-poll.sh, predictor-run.sh, supervisor-run.sh. | cron entry points |
|
||||
| `lib/mirrors.sh` | `mirror_push()` — pushes `$PRIMARY_BRANCH` + tags to all configured mirror remotes (fire-and-forget background pushes). Reads `MIRROR_NAMES` and `MIRROR_*` vars exported by `load-project.sh` from the `[mirrors]` TOML section. Failures are logged but never block the pipeline. Sourced by dev-poll.sh and dev/phase-handler.sh — called after every successful merge. | dev-poll.sh, phase-handler.sh |
|
||||
| `lib/build-graph.py` | Python tool: parses VISION.md, prerequisites.md (from ops repo), AGENTS.md, formulas/*.toml, evidence/ (from ops repo), and forge issues/labels into a NetworkX DiGraph. Runs structural analyses (orphaned objectives, stale prerequisites, thin evidence, circular deps) and outputs a JSON report. Used by `review-pr.sh` (per-PR changed-file analysis) and `predictor-run.sh` (full-project analysis) to provide structural context to Claude. | review-pr.sh, predictor-run.sh |
|
||||
| `lib/secret-scan.sh` | `scan_for_secrets()` — detects potential secrets (API keys, bearer tokens, private keys, URLs with embedded credentials) in text; returns 1 if secrets found. `redact_secrets()` — replaces detected secret patterns with `[REDACTED]`. | file-action-issue.sh, phase-handler.sh |
|
||||
| `lib/file-action-issue.sh` | `file_action_issue()` — dedup check, secret scan, label lookup, and issue creation for formula-driven cron wrappers. Sets `FILED_ISSUE_NUM` on success. Returns 4 if secrets detected in body. | (available for future use) |
|
||||
| `lib/tea-helpers.sh` | `tea_file_issue(title, body, labels...)` — create issue via tea CLI with secret scanning; sets `FILED_ISSUE_NUM`. `tea_relabel(issue_num, labels...)` — replace labels using tea's `edit` subcommand (not `label`). `tea_comment(issue_num, body)` — add comment with secret scanning. `tea_close(issue_num)` — close issue. All use `TEA_LOGIN` and `FORGE_REPO` from env.sh. Labels by name (no ID lookup). Tea binary download verified via sha256 checksum. Sourced by env.sh when `tea` binary is available. | env.sh (conditional) |
|
||||
| `lib/worktree.sh` | Reusable git worktree management: `worktree_create(path, branch, [base_ref])` — create worktree, checkout base, fetch submodules. `worktree_recover(path, branch, [remote])` — detect existing worktree, reuse if on correct branch (sets `_WORKTREE_REUSED`), otherwise clean and recreate. `worktree_cleanup(path)` — `git worktree remove --force`, clear Claude Code project cache (`~/.claude/projects/` matching path). `worktree_cleanup_stale([max_age_hours])` — scan `/tmp` for orphaned worktrees older than threshold, skip preserved and active tmux worktrees, prune. `worktree_preserve(path, reason)` — mark worktree as preserved for debugging (writes `.worktree-preserved` marker, skipped by stale cleanup). | dev-agent.sh, 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), 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), 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, action-agent.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, supervisor-run.sh, planner-run.sh, predictor-run.sh, gardener-run.sh |
|
||||
| `lib/pr-lifecycle.sh` | Reusable PR lifecycle library: `pr_create()`, `pr_find_by_branch()`, `pr_poll_ci()`, `pr_poll_review()`, `pr_merge()`, `pr_is_merged()`, `pr_walk_to_merge()`, `build_phase_protocol_prompt()`. Requires `lib/ci-helpers.sh`. | dev-agent.sh (future) |
|
||||
| `lib/issue-lifecycle.sh` | Reusable issue lifecycle library: `issue_claim()` (add in-progress, remove backlog), `issue_release()` (remove in-progress, add backlog), `issue_block()` (post diagnostic comment with secret redaction, add blocked label), `issue_close()`, `issue_check_deps()` (parse deps, check transitive closure; sets `_ISSUE_BLOCKED_BY`, `_ISSUE_SUGGESTION`), `issue_suggest_next()` (find next unblocked backlog issue; sets `_ISSUE_NEXT`), `issue_post_refusal()` (structured refusal comment with dedup). Label IDs cached in globals on first lookup. Sources `lib/secret-scan.sh`. | dev-agent.sh (future) |
|
||||
| `lib/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/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 |
|
||||
|
|
|
|||
|
|
@ -58,4 +58,35 @@ agent_run() {
|
|||
printf '%s' "$new_sid" > "$SID_FILE"
|
||||
log "agent_run: session_id=${new_sid:0:12}..."
|
||||
fi
|
||||
|
||||
# Save output for diagnostics (no_push, crashes)
|
||||
_AGENT_LAST_OUTPUT="$output"
|
||||
local diag_file="${DISINTO_LOG_DIR:-/tmp}/dev/agent-run-last.json"
|
||||
printf '%s' "$output" > "$diag_file" 2>/dev/null || true
|
||||
|
||||
# Nudge: if the model stopped without pushing, resume with encouragement.
|
||||
# Some models emit end_turn prematurely when confused. A nudge often unsticks them.
|
||||
if [ -n "$_AGENT_SESSION_ID" ]; 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
|
||||
local nudge="You stopped but did not push any code. "
|
||||
if [ -n "$has_changes" ]; then
|
||||
nudge+="You have uncommitted changes. Commit them and push."
|
||||
else
|
||||
nudge+="Complete the implementation, commit, and push your branch."
|
||||
fi
|
||||
log "agent_run: nudging (no push detected)"
|
||||
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"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
|
|
|||
426
lib/branch-protection.sh
Normal file
426
lib/branch-protection.sh
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
#!/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
|
||||
local branch_exists
|
||||
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 "ERROR: Branch ${branch} does not exist"
|
||||
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
|
||||
local branch_exists
|
||||
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 "ERROR: Branch ${branch} does not exist on ${repo}"
|
||||
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
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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}"
|
||||
;;
|
||||
verify)
|
||||
verify_branch_protection "${2:-main}"
|
||||
;;
|
||||
remove)
|
||||
remove_branch_protection "${2:-main}"
|
||||
;;
|
||||
help|*)
|
||||
echo "Usage: $0 {setup|setup-profile|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 " 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., johba/disinto-ops)"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
|
@ -17,6 +17,11 @@ REPO="${FORGE_REPO}"
|
|||
API="${WOODPECKER_SERVER}/api/repos/${WOODPECKER_REPO_ID}"
|
||||
|
||||
api() {
|
||||
# Validate API URL to prevent URL injection
|
||||
if ! validate_url "$API"; then
|
||||
echo "ERROR: API URL validation failed - possible URL injection attempt" >&2
|
||||
return 1
|
||||
fi
|
||||
curl -sf -H "Authorization: Bearer ${WOODPECKER_TOKEN}" "${API}/$1"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -267,3 +267,42 @@ ci_promote() {
|
|||
|
||||
echo "$new_num"
|
||||
}
|
||||
|
||||
# ci_get_logs <pipeline_number> [--step <step_name>]
|
||||
# Reads CI logs from the Woodpecker SQLite database.
|
||||
# Requires: WOODPECKER_DATA_DIR env var or mounted volume at /woodpecker-data
|
||||
# Returns: 0 on success, 1 on failure. Outputs log text to stdout.
|
||||
#
|
||||
# Usage:
|
||||
# ci_get_logs 346 # Get all failed step logs
|
||||
# ci_get_logs 346 --step smoke-init # Get logs for specific step
|
||||
ci_get_logs() {
|
||||
local pipeline_number="$1"
|
||||
shift || true
|
||||
|
||||
local step_name=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--step|-s)
|
||||
step_name="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
local log_reader="${FACTORY_ROOT:-/home/agent/disinto}/lib/ci-log-reader.py"
|
||||
if [ -f "$log_reader" ]; then
|
||||
if [ -n "$step_name" ]; then
|
||||
python3 "$log_reader" "$pipeline_number" --step "$step_name"
|
||||
else
|
||||
python3 "$log_reader" "$pipeline_number"
|
||||
fi
|
||||
else
|
||||
echo "ERROR: ci-log-reader.py not found at $log_reader" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
|
|
|||
125
lib/ci-log-reader.py
Executable file
125
lib/ci-log-reader.py
Executable file
|
|
@ -0,0 +1,125 @@
|
|||
#!/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()
|
||||
106
lib/env.sh
106
lib/env.sh
|
|
@ -28,18 +28,44 @@ export DISINTO_LOG_DIR
|
|||
if [ -f "$FACTORY_ROOT/.env.enc" ] && command -v sops &>/dev/null; then
|
||||
set -a
|
||||
_saved_forge_url="${FORGE_URL:-}"
|
||||
eval "$(sops -d --output-type dotenv "$FACTORY_ROOT/.env.enc" 2>/dev/null)" \
|
||||
|| echo "Warning: failed to decrypt .env.enc — secrets not loaded" >&2
|
||||
_saved_forge_token="${FORGE_TOKEN:-}"
|
||||
# Use temp file + validate dotenv format before sourcing (avoids eval injection)
|
||||
# SOPS -d automatically verifies MAC/GCM authentication tag during decryption
|
||||
_tmpenv=$(mktemp) || { echo "Error: failed to create temp file for .env.enc" >&2; exit 1; }
|
||||
if ! sops -d --output-type dotenv "$FACTORY_ROOT/.env.enc" > "$_tmpenv" 2>/dev/null; then
|
||||
echo "Error: failed to decrypt .env.enc — decryption failed, possible corruption" >&2
|
||||
rm -f "$_tmpenv"
|
||||
exit 1
|
||||
fi
|
||||
# Validate: non-empty, non-comment lines must match KEY=value pattern
|
||||
# Filter out blank lines and comments before validation
|
||||
_validated=$(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$_tmpenv" 2>/dev/null || true)
|
||||
if [ -n "$_validated" ]; then
|
||||
# Write validated content to a second temp file and source it
|
||||
_validated_env=$(mktemp)
|
||||
printf '%s\n' "$_validated" > "$_validated_env"
|
||||
# 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
|
||||
[ -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
|
||||
# Preserve compose-injected FORGE_URL (localhost in .env != forgejo in Docker)
|
||||
_saved_forge_url="${FORGE_URL:-}"
|
||||
_saved_forge_token="${FORGE_TOKEN:-}"
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
source "$FACTORY_ROOT/.env"
|
||||
set +a
|
||||
[ -n "$_saved_forge_url" ] && export FORGE_URL="$_saved_forge_url"
|
||||
[ -n "$_saved_forge_token" ] && export FORGE_TOKEN="$_saved_forge_token"
|
||||
fi
|
||||
|
||||
# PATH: foundry, node, system
|
||||
|
|
@ -69,10 +95,10 @@ export FORGE_GARDENER_TOKEN="${FORGE_GARDENER_TOKEN:-${FORGE_TOKEN}}"
|
|||
export FORGE_VAULT_TOKEN="${FORGE_VAULT_TOKEN:-${FORGE_TOKEN}}"
|
||||
export FORGE_SUPERVISOR_TOKEN="${FORGE_SUPERVISOR_TOKEN:-${FORGE_TOKEN}}"
|
||||
export FORGE_PREDICTOR_TOKEN="${FORGE_PREDICTOR_TOKEN:-${FORGE_TOKEN}}"
|
||||
export FORGE_ACTION_TOKEN="${FORGE_ACTION_TOKEN:-${FORGE_TOKEN}}"
|
||||
export FORGE_ARCHITECT_TOKEN="${FORGE_ARCHITECT_TOKEN:-${FORGE_TOKEN}}"
|
||||
|
||||
# Bot usernames filter: FORGE_BOT_USERNAMES > legacy CODEBERG_BOT_USERNAMES
|
||||
export FORGE_BOT_USERNAMES="${FORGE_BOT_USERNAMES:-${CODEBERG_BOT_USERNAMES:-dev-bot,review-bot,planner-bot,gardener-bot,vault-bot,supervisor-bot,predictor-bot,action-bot}}"
|
||||
export 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 CODEBERG_BOT_USERNAMES="${FORGE_BOT_USERNAMES}" # backwards compat
|
||||
|
||||
# Project config (FORGE_* preferred, CODEBERG_* fallback)
|
||||
|
|
@ -108,7 +134,7 @@ export CLAUDE_TIMEOUT="${CLAUDE_TIMEOUT:-7200}"
|
|||
|
||||
# Vault-only token guard (#745): external-action tokens (GITHUB_TOKEN, CLAWHUB_TOKEN)
|
||||
# must NEVER be available to agents. They live in .env.vault.enc and are injected
|
||||
# only into the ephemeral vault-runner container at fire time. Unset them here so
|
||||
# only into the ephemeral runner container at fire time. Unset them here so
|
||||
# even an accidental .env inclusion cannot leak them into agent sessions.
|
||||
unset GITHUB_TOKEN 2>/dev/null || true
|
||||
unset CLAWHUB_TOKEN 2>/dev/null || true
|
||||
|
|
@ -122,10 +148,62 @@ log() {
|
|||
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() {
|
||||
local method="$1" path="$2"
|
||||
shift 2
|
||||
|
||||
# Validate FORGE_API to prevent URL injection
|
||||
if ! validate_url "$FORGE_API"; then
|
||||
echo "ERROR: FORGE_API validation failed - possible URL injection attempt" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
curl -sf -X "$method" \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
|
|
@ -157,13 +235,23 @@ forge_api_all() {
|
|||
done
|
||||
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() {
|
||||
local path="$1"
|
||||
shift
|
||||
|
||||
# Validate WOODPECKER_SERVER to prevent URL injection
|
||||
if ! validate_url "$WOODPECKER_SERVER"; then
|
||||
echo "ERROR: WOODPECKER_SERVER validation failed - possible URL injection attempt" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
curl -sfL \
|
||||
-H "Authorization: Bearer ${WOODPECKER_TOKEN}" \
|
||||
"${WOODPECKER_SERVER}/api${path}" "$@"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
# build_prompt_footer [EXTRA_API] — sets PROMPT_FOOTER (API ref + env + phase)
|
||||
# run_formula_and_monitor AGENT [TIMEOUT] [CALLBACK] — session start, inject, monitor, log
|
||||
# formula_phase_callback PHASE — standard crash-recovery callback
|
||||
# formula_prepare_profile_context — load lessons from .profile repo (pre-session)
|
||||
#
|
||||
# Requires: lib/agent-session.sh sourced first (for create_agent_session,
|
||||
# agent_kill_session, agent_inject_into_session).
|
||||
|
|
@ -51,6 +52,417 @@ check_memory() {
|
|||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
# load_formula FORMULA_FILE
|
||||
|
|
@ -65,6 +477,60 @@ load_formula() {
|
|||
FORMULA_CONTENT=$(cat "$formula_file")
|
||||
}
|
||||
|
||||
# load_formula_or_profile [ROLE] [FORMULA_FILE]
|
||||
# Tries to load formula from .profile repo first, falls back to formulas/<role>.toml.
|
||||
# Requires: AGENT_IDENTITY, ensure_profile_repo() available.
|
||||
# Exports: FORMULA_CONTENT, FORMULA_SOURCE (either ".profile" or "formulas/").
|
||||
# Returns 0 on success, 1 on failure.
|
||||
load_formula_or_profile() {
|
||||
local role="${1:-}"
|
||||
local fallback_formula="${2:-}"
|
||||
|
||||
# Try to load from .profile repo
|
||||
if [ -n "$AGENT_IDENTITY" ] && ensure_profile_repo "$AGENT_IDENTITY"; then
|
||||
if [ -f "$PROFILE_FORMULA_PATH" ]; then
|
||||
log "formula source: .profile (${PROFILE_FORMULA_PATH})"
|
||||
# shellcheck disable=SC2034
|
||||
FORMULA_CONTENT="$(cat "$PROFILE_FORMULA_PATH")"
|
||||
FORMULA_SOURCE=".profile"
|
||||
return 0
|
||||
else
|
||||
log "WARNING: .profile repo exists but formula.toml not found at ${PROFILE_FORMULA_PATH}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback to formulas/<role>.toml
|
||||
if [ -n "$fallback_formula" ]; then
|
||||
if [ -f "$fallback_formula" ]; then
|
||||
log "formula source: formulas/ (fallback) — ${fallback_formula}"
|
||||
# shellcheck disable=SC2034
|
||||
FORMULA_CONTENT="$(cat "$fallback_formula")"
|
||||
FORMULA_SOURCE="formulas/"
|
||||
return 0
|
||||
else
|
||||
log "ERROR: formula not found in .profile and fallback file not found: $fallback_formula"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# No fallback specified but role provided — construct fallback path
|
||||
if [ -n "$role" ]; then
|
||||
fallback_formula="${FACTORY_ROOT}/formulas/${role}.toml"
|
||||
if [ -f "$fallback_formula" ]; then
|
||||
log "formula source: formulas/ (fallback) — ${fallback_formula}"
|
||||
# shellcheck disable=SC2034
|
||||
FORMULA_CONTENT="$(cat "$fallback_formula")"
|
||||
# shellcheck disable=SC2034
|
||||
FORMULA_SOURCE="formulas/"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# No fallback specified
|
||||
log "ERROR: formula not found in .profile and no fallback specified"
|
||||
return 1
|
||||
}
|
||||
|
||||
# build_context_block FILE [FILE ...]
|
||||
# Reads each file from $PROJECT_REPO_ROOT and builds CONTEXT_BLOCK.
|
||||
# Files prefixed with "ops:" are read from $OPS_REPO_ROOT instead.
|
||||
|
|
@ -283,8 +749,14 @@ build_graph_section() {
|
|||
--project-root "$PROJECT_REPO_ROOT" \
|
||||
--output "$report" 2>>"$LOG_FILE"; then
|
||||
# shellcheck disable=SC2034
|
||||
GRAPH_SECTION=$(printf '\n## Structural analysis\n```json\n%s\n```\n' \
|
||||
"$(cat "$report")")
|
||||
local report_content
|
||||
report_content="$(cat "$report")"
|
||||
# shellcheck disable=SC2034
|
||||
GRAPH_SECTION="
|
||||
## Structural analysis
|
||||
\`\`\`json
|
||||
${report_content}
|
||||
\`\`\`"
|
||||
log "graph report generated: $(jq -r '.stats | "\(.nodes) nodes, \(.edges) edges"' "$report")"
|
||||
else
|
||||
log "WARN: build-graph.py failed — continuing without structural analysis"
|
||||
|
|
|
|||
|
|
@ -45,16 +45,16 @@ _ilc_log() {
|
|||
# Label ID caching — lookup once per name, cache in globals.
|
||||
# Pattern follows ci-helpers.sh (ensure_blocked_label_id).
|
||||
# ---------------------------------------------------------------------------
|
||||
_ILC_BACKLOG_ID=""
|
||||
_ILC_IN_PROGRESS_ID=""
|
||||
_ILC_BLOCKED_ID=""
|
||||
declare -A _ILC_LABEL_IDS
|
||||
_ILC_LABEL_IDS["backlog"]=""
|
||||
_ILC_LABEL_IDS["in-progress"]=""
|
||||
_ILC_LABEL_IDS["blocked"]=""
|
||||
|
||||
# _ilc_ensure_label_id VARNAME LABEL_NAME [COLOR]
|
||||
# Generic: looks up label by name, creates if missing, caches in the named var.
|
||||
# _ilc_ensure_label_id LABEL_NAME [COLOR]
|
||||
# Looks up label by name, creates if missing, caches in associative array.
|
||||
_ilc_ensure_label_id() {
|
||||
local varname="$1" name="$2" color="${3:-#e0e0e0}"
|
||||
local current
|
||||
eval "current=\"\${${varname}:-}\""
|
||||
local name="$1" color="${2:-#e0e0e0}"
|
||||
local current="${_ILC_LABEL_IDS[$name]:-}"
|
||||
if [ -n "$current" ]; then
|
||||
printf '%s' "$current"
|
||||
return 0
|
||||
|
|
@ -71,21 +71,45 @@ _ilc_ensure_label_id() {
|
|||
| jq -r '.id // empty' 2>/dev/null || true)
|
||||
fi
|
||||
if [ -n "$label_id" ]; then
|
||||
eval "${varname}=\"${label_id}\""
|
||||
_ILC_LABEL_IDS["$name"]="$label_id"
|
||||
fi
|
||||
printf '%s' "$label_id"
|
||||
}
|
||||
|
||||
_ilc_backlog_id() { _ilc_ensure_label_id _ILC_BACKLOG_ID "backlog" "#0075ca"; }
|
||||
_ilc_in_progress_id() { _ilc_ensure_label_id _ILC_IN_PROGRESS_ID "in-progress" "#1d76db"; }
|
||||
_ilc_blocked_id() { _ilc_ensure_label_id _ILC_BLOCKED_ID "blocked" "#e11d48"; }
|
||||
_ilc_backlog_id() { _ilc_ensure_label_id "backlog" "#0075ca"; }
|
||||
_ilc_in_progress_id() { _ilc_ensure_label_id "in-progress" "#1d76db"; }
|
||||
_ilc_blocked_id() { _ilc_ensure_label_id "blocked" "#e11d48"; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# issue_claim — add "in-progress" label, remove "backlog" label.
|
||||
# issue_claim — assign issue to bot, add "in-progress" label, remove "backlog".
|
||||
# Args: issue_number
|
||||
# Returns: 0 on success, 1 if already assigned to another agent
|
||||
# ---------------------------------------------------------------------------
|
||||
issue_claim() {
|
||||
local issue="$1"
|
||||
|
||||
# Get current bot identity
|
||||
local me
|
||||
me=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_URL}/api/v1/user" | jq -r '.login') || return 1
|
||||
|
||||
# Check current assignee
|
||||
local current
|
||||
current=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues/${issue}" | jq -r '.assignee.login // ""') || return 1
|
||||
|
||||
if [ -n "$current" ] && [ "$current" != "$me" ]; then
|
||||
_ilc_log "issue #${issue} already assigned to ${current} — skipping"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Assign to self (Forgejo rejects if already assigned differently)
|
||||
curl -sf -X PATCH \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/issues/${issue}" \
|
||||
-d "{\"assignees\":[\"${me}\"]}" >/dev/null 2>&1 || return 1
|
||||
|
||||
local ip_id bl_id
|
||||
ip_id=$(_ilc_in_progress_id)
|
||||
bl_id=$(_ilc_backlog_id)
|
||||
|
|
@ -102,14 +126,23 @@ issue_claim() {
|
|||
"${FORGE_API}/issues/${issue}/labels/${bl_id}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
_ilc_log "claimed issue #${issue}"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# issue_release — remove "in-progress" label, add "backlog" label.
|
||||
# issue_release — remove "in-progress" label, add "backlog" label, clear assignee.
|
||||
# Args: issue_number
|
||||
# ---------------------------------------------------------------------------
|
||||
issue_release() {
|
||||
local issue="$1"
|
||||
|
||||
# Clear assignee
|
||||
curl -sf -X PATCH \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/issues/${issue}" \
|
||||
-d '{"assignees":[]}' >/dev/null 2>&1 || true
|
||||
|
||||
local ip_id bl_id
|
||||
ip_id=$(_ilc_in_progress_id)
|
||||
bl_id=$(_ilc_backlog_id)
|
||||
|
|
@ -184,11 +217,19 @@ issue_block() {
|
|||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# issue_close — PATCH state to closed.
|
||||
# issue_close — clear assignee, PATCH state to closed.
|
||||
# Args: issue_number
|
||||
# ---------------------------------------------------------------------------
|
||||
issue_close() {
|
||||
local issue="$1"
|
||||
|
||||
# Clear assignee before closing
|
||||
curl -sf -X PATCH \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/issues/${issue}" \
|
||||
-d '{"assignees":[]}' >/dev/null 2>&1 || true
|
||||
|
||||
curl -sf -X PATCH \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
|
|
|
|||
|
|
@ -13,7 +13,16 @@ mirror_push() {
|
|||
|
||||
local name url
|
||||
for name in $MIRROR_NAMES; do
|
||||
url=$(eval "echo \"\$MIRROR_$(echo "$name" | tr '[:lower:]' '[:upper:]')\"") || true
|
||||
# Convert name to uppercase env var name safely (only alphanumeric allowed)
|
||||
local upper_name
|
||||
upper_name=$(printf '%s' "$name" | tr '[:lower:]' '[:upper:]')
|
||||
# Validate: only allow alphanumeric + underscore in var name
|
||||
if [[ ! "$upper_name" =~ ^[A-Z_][A-Z0-9_]*$ ]]; then
|
||||
continue
|
||||
fi
|
||||
# Use indirect expansion safely (no eval) — MIRROR_ prefix required
|
||||
local varname="MIRROR_${upper_name}"
|
||||
url="${!varname:-}"
|
||||
[ -z "$url" ] && continue
|
||||
|
||||
# Ensure remote exists with correct URL
|
||||
|
|
|
|||
|
|
@ -61,13 +61,15 @@ _prl_log() {
|
|||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pr_create — Create a PR via forge API.
|
||||
# Args: branch title body [base_branch]
|
||||
# Args: branch title body [base_branch] [api_url]
|
||||
# Stdout: PR number
|
||||
# Returns: 0=created (or found existing), 1=failed
|
||||
# api_url defaults to FORGE_API if not provided
|
||||
# ---------------------------------------------------------------------------
|
||||
pr_create() {
|
||||
local branch="$1" title="$2" body="$3"
|
||||
local base="${4:-${PRIMARY_BRANCH:-main}}"
|
||||
local api_url="${5:-${FORGE_API}}"
|
||||
local tmpfile resp http_code resp_body pr_num
|
||||
|
||||
tmpfile=$(mktemp /tmp/prl-create-XXXXXX.json)
|
||||
|
|
@ -77,7 +79,7 @@ pr_create() {
|
|||
resp=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/pulls" \
|
||||
"${api_url}/pulls" \
|
||||
--data-binary @"$tmpfile") || true
|
||||
rm -f "$tmpfile"
|
||||
|
||||
|
|
@ -92,7 +94,7 @@ pr_create() {
|
|||
return 0
|
||||
;;
|
||||
409)
|
||||
pr_num=$(pr_find_by_branch "$branch") || true
|
||||
pr_num=$(pr_find_by_branch "$branch" "$api_url") || true
|
||||
if [ -n "$pr_num" ]; then
|
||||
_prl_log "PR already exists: #${pr_num}"
|
||||
printf '%s' "$pr_num"
|
||||
|
|
@ -110,15 +112,17 @@ pr_create() {
|
|||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pr_find_by_branch — Find an open PR by head branch name.
|
||||
# Args: branch
|
||||
# Args: branch [api_url]
|
||||
# Stdout: PR number
|
||||
# Returns: 0=found, 1=not found
|
||||
# api_url defaults to FORGE_API if not provided
|
||||
# ---------------------------------------------------------------------------
|
||||
pr_find_by_branch() {
|
||||
local branch="$1"
|
||||
local api_url="${2:-${FORGE_API}}"
|
||||
local pr_num
|
||||
pr_num=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/pulls?state=open&limit=20" | \
|
||||
"${api_url}/pulls?state=open&limit=20" | \
|
||||
jq -r --arg b "$branch" '.[] | select(.head.ref == $b) | .number' \
|
||||
| head -1) || true
|
||||
if [ -n "$pr_num" ]; then
|
||||
|
|
@ -344,6 +348,22 @@ pr_is_merged() {
|
|||
[ "$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.
|
||||
#
|
||||
|
|
@ -394,6 +414,23 @@ pr_walk_to_merge() {
|
|||
fi
|
||||
|
||||
_prl_log "CI failed — invoking agent (attempt ${ci_fix_count}/${max_ci_fixes})"
|
||||
|
||||
# Get CI logs from SQLite database if available
|
||||
local ci_logs=""
|
||||
if [ -n "$_PR_CI_PIPELINE" ] && [ -n "${FACTORY_ROOT:-}" ]; then
|
||||
ci_logs=$(ci_get_logs "$_PR_CI_PIPELINE" 2>/dev/null | tail -50) || ci_logs=""
|
||||
fi
|
||||
|
||||
local logs_section=""
|
||||
if [ -n "$ci_logs" ]; then
|
||||
logs_section="
|
||||
CI Log Output (last 50 lines):
|
||||
\`\`\`
|
||||
${ci_logs}
|
||||
\`\`\`
|
||||
"
|
||||
fi
|
||||
|
||||
agent_run --resume "$session_id" --worktree "$worktree" \
|
||||
"CI failed on PR #${pr_num} (attempt ${ci_fix_count}/${max_ci_fixes}).
|
||||
|
||||
|
|
@ -401,7 +438,7 @@ Pipeline: #${_PR_CI_PIPELINE:-?}
|
|||
Failure type: ${_PR_CI_FAILURE_TYPE:-unknown}
|
||||
|
||||
Error log:
|
||||
${_PR_CI_ERROR_LOG:-No logs available.}
|
||||
${_PR_CI_ERROR_LOG:-No logs available.}${logs_section}
|
||||
|
||||
Fix the issue, run tests, commit, rebase on ${PRIMARY_BRANCH}, and push:
|
||||
git fetch ${remote} ${PRIMARY_BRANCH} && git rebase ${remote}/${PRIMARY_BRANCH}
|
||||
|
|
|
|||
210
lib/profile.sh
Normal file
210
lib/profile.sh
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
#!/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 $?
|
||||
}
|
||||
222
lib/vault.sh
Normal file
222
lib/vault.sh
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
#!/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
|
||||
}
|
||||
|
||||
# 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
|
||||
}
|
||||
|
|
@ -22,12 +22,13 @@ to detect issues ping-ponging between backlog and underspecified. Issues that
|
|||
need human decisions or external resources are filed as vault procurement items
|
||||
(`$OPS_REPO_ROOT/vault/pending/*.md`) instead of being escalated. Phase 3
|
||||
(file-at-constraints): identify the top 3 unresolved prerequisites that block
|
||||
the most downstream objectives — file issues as either `backlog` (code changes,
|
||||
dev-agent) or `action` (run existing formula, action-agent). **Stuck issues
|
||||
(detected BOUNCED/LABEL_CHURN) are dispatched to the `groom-backlog` formula
|
||||
in breakdown mode instead of being re-promoted** — this breaks the ping-pong
|
||||
loop by splitting them into dev-agent-sized sub-issues. **Human-blocked issues
|
||||
are routed through the vault** — the planner files an actionable procurement
|
||||
the most downstream objectives — file issues using a **template-or-vision gate**:
|
||||
read issue templates from `.codeberg/ISSUE_TEMPLATE/*.yaml`, attempt to fill
|
||||
template fields (affected_files ≤3, acceptance_criteria ≤5, single clear approach),
|
||||
then apply complexity test: if work touches one subsystem with no design forks,
|
||||
file as `backlog` using matching template (bug/feature/refactor); otherwise
|
||||
label `vision` with problem statement and why it's vision-sized. **Human-blocked
|
||||
issues are routed through the vault** — the planner files an actionable procurement
|
||||
item (`$OPS_REPO_ROOT/vault/pending/<project>-<slug>.md` with What/Why/Human action/Factory
|
||||
will then sections) and marks the prerequisite as blocked-on-vault in the tree.
|
||||
Deduplication: checks pending/ + approved/ + fired/ before creating.
|
||||
|
|
@ -56,9 +57,9 @@ component, not work.
|
|||
prediction-triage, update-prerequisite-tree, file-at-constraints,
|
||||
journal-and-memory, commit-and-pr) with `needs` dependencies. Claude
|
||||
executes all steps in a single interactive session with tool access
|
||||
- `formulas/groom-backlog.toml` — Dual-mode formula: grooming (default) or
|
||||
breakdown (dispatched by planner for bounced/stuck issues — splits the issue
|
||||
into dev-agent-sized sub-issues, removes `underspecified` label)
|
||||
- `formulas/groom-backlog.toml` — Grooming formula for backlog triage and
|
||||
grooming. (Note: the planner no longer dispatches breakdown mode — complex
|
||||
issues are labeled `vision` instead.)
|
||||
- `$OPS_REPO_ROOT/prerequisites.md` — Prerequisite tree: versioned constraint
|
||||
map linking VISION.md objectives to their prerequisites. Planner owns the
|
||||
tree, humans steer by editing VISION.md. Tree grows organically as the
|
||||
|
|
|
|||
|
|
@ -52,8 +52,14 @@ check_memory 2000
|
|||
|
||||
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 "$FACTORY_ROOT/formulas/run-planner.toml"
|
||||
load_formula_or_profile "planner" "$FACTORY_ROOT/formulas/run-planner.toml" || exit 1
|
||||
build_context_block VISION.md AGENTS.md ops:RESOURCES.md ops:prerequisites.md
|
||||
|
||||
# ── Build structural analysis graph ──────────────────────────────────────
|
||||
|
|
@ -72,24 +78,8 @@ $(cat "$MEMORY_FILE")
|
|||
"
|
||||
fi
|
||||
|
||||
# ── Read recent journal files ──────────────────────────────────────────
|
||||
JOURNAL_BLOCK=""
|
||||
JOURNAL_DIR="$OPS_REPO_ROOT/journal/planner"
|
||||
if [ -d "$JOURNAL_DIR" ]; then
|
||||
# Load last 5 journal files (most recent first) for run history context
|
||||
JOURNAL_FILES=$(find "$JOURNAL_DIR" -name '*.md' -type f | sort -r | head -5)
|
||||
if [ -n "$JOURNAL_FILES" ]; then
|
||||
JOURNAL_BLOCK="
|
||||
### Recent journal entries (journal/planner/)
|
||||
"
|
||||
while IFS= read -r jf; do
|
||||
JOURNAL_BLOCK="${JOURNAL_BLOCK}
|
||||
#### $(basename "$jf")
|
||||
$(cat "$jf")
|
||||
"
|
||||
done <<< "$JOURNAL_FILES"
|
||||
fi
|
||||
fi
|
||||
# ── Prepare .profile context (lessons injection) ─────────────────────────
|
||||
formula_prepare_profile_context
|
||||
|
||||
# ── Read scratch file (compaction survival) ───────────────────────────────
|
||||
SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
|
||||
|
|
@ -105,7 +95,7 @@ build_sdk_prompt_footer "
|
|||
PROMPT="You are the strategic planner for ${FORGE_REPO}. Work through the formula below.
|
||||
|
||||
## Project context
|
||||
${CONTEXT_BLOCK}${MEMORY_BLOCK}${JOURNAL_BLOCK}
|
||||
${CONTEXT_BLOCK}${MEMORY_BLOCK}$(formula_lessons_block)
|
||||
${GRAPH_SECTION}
|
||||
${SCRATCH_CONTEXT:+${SCRATCH_CONTEXT}
|
||||
}
|
||||
|
|
@ -125,5 +115,8 @@ export CLAUDE_MODEL="opus"
|
|||
agent_run --worktree "$WORKTREE" "$PROMPT"
|
||||
log "agent_run complete"
|
||||
|
||||
# Write journal entry post-session
|
||||
profile_write_journal "planner-run" "Planner run $(date -u +%Y-%m-%d)" "complete" "" || true
|
||||
|
||||
rm -f "$SCRATCH_FILE"
|
||||
log "--- Planner run done ---"
|
||||
|
|
|
|||
|
|
@ -53,13 +53,22 @@ check_memory 2000
|
|||
|
||||
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 "$FACTORY_ROOT/formulas/run-predictor.toml"
|
||||
load_formula_or_profile "predictor" "$FACTORY_ROOT/formulas/run-predictor.toml" || exit 1
|
||||
build_context_block AGENTS.md ops:RESOURCES.md VISION.md ops:prerequisites.md
|
||||
|
||||
# ── Build structural analysis graph ──────────────────────────────────────
|
||||
build_graph_section
|
||||
|
||||
# ── Prepare .profile context (lessons injection) ─────────────────────────
|
||||
formula_prepare_profile_context
|
||||
|
||||
# ── Read scratch file (compaction survival) ───────────────────────────────
|
||||
SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
|
||||
SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
|
||||
|
|
@ -82,9 +91,10 @@ Use WebSearch for external signal scanning — be targeted (project dependencies
|
|||
and tools only, not general news). Limit to 3 web searches per run.
|
||||
|
||||
## Project context
|
||||
${CONTEXT_BLOCK}
|
||||
${CONTEXT_BLOCK}$(formula_lessons_block)
|
||||
${GRAPH_SECTION}
|
||||
${SCRATCH_CONTEXT}
|
||||
${SCRATCH_CONTEXT:+${SCRATCH_CONTEXT}
|
||||
}
|
||||
## Formula
|
||||
${FORMULA_CONTENT}
|
||||
|
||||
|
|
@ -98,5 +108,8 @@ formula_worktree_setup "$WORKTREE"
|
|||
agent_run --worktree "$WORKTREE" "$PROMPT"
|
||||
log "agent_run complete"
|
||||
|
||||
# Write journal entry post-session
|
||||
profile_write_journal "predictor-run" "Predictor run $(date -u +%Y-%m-%d)" "complete" "" || true
|
||||
|
||||
rm -f "$SCRATCH_FILE"
|
||||
log "--- Predictor run done ---"
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ source "$(dirname "$0")/../lib/env.sh"
|
|||
source "$(dirname "$0")/../lib/ci-helpers.sh"
|
||||
source "$(dirname "$0")/../lib/worktree.sh"
|
||||
source "$(dirname "$0")/../lib/agent-sdk.sh"
|
||||
# shellcheck source=../lib/formula-session.sh
|
||||
source "$(dirname "$0")/../lib/formula-session.sh"
|
||||
|
||||
# Auto-pull factory code to pick up merged fixes before any logic runs
|
||||
git -C "$FACTORY_ROOT" pull --ff-only origin main 2>/dev/null || true
|
||||
|
|
@ -56,6 +58,14 @@ if [ -f "$LOGFILE" ] && [ "$(stat -c%s "$LOGFILE" 2>/dev/null || echo 0)" -gt 10
|
|||
mv "$LOGFILE" "$LOGFILE.old"
|
||||
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
|
||||
# =============================================================================
|
||||
|
|
@ -180,6 +190,11 @@ else
|
|||
log "WARN: build-graph.py failed — continuing without structural analysis"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# LOAD LESSONS FROM .PROFILE REPO (PRE-SESSION)
|
||||
# =============================================================================
|
||||
formula_prepare_profile_context
|
||||
|
||||
# =============================================================================
|
||||
# BUILD PROMPT
|
||||
# =============================================================================
|
||||
|
|
@ -193,6 +208,7 @@ FORMULA=$(cat "${FACTORY_ROOT}/formulas/review-pr.toml")
|
|||
"$PR_BODY" "$FILES" "$DNOTE" "$DIFF"
|
||||
[ -n "$PREV_CONTEXT" ] && printf '%s\n' "$PREV_CONTEXT"
|
||||
[ -n "$GRAPH_SECTION" ] && printf '%s\n' "$GRAPH_SECTION"
|
||||
formula_lessons_block
|
||||
printf '\n## Formula\n%s\n\n## Environment\nREVIEW_OUTPUT_FILE=%s\nFORGE_API=%s\nPR_NUMBER=%s\nFACTORY_ROOT=%s\n' \
|
||||
"$FORMULA" "$OUTPUT_FILE" "$API" "$PR_NUMBER" "$FACTORY_ROOT"
|
||||
printf 'NEVER echo the actual token — always reference ${FORGE_TOKEN} or ${FORGE_REVIEW_TOKEN}.\n'
|
||||
|
|
@ -298,4 +314,7 @@ case "$VERDICT" in
|
|||
;;
|
||||
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})"
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ collect_agent_metrics() {
|
|||
local agent_name log_path age_min last_active
|
||||
for log_entry in dev/dev-agent.log review/review.log gardener/gardener.log \
|
||||
planner/planner.log predictor/predictor.log supervisor/supervisor.log \
|
||||
action/action.log vault/vault.log; do
|
||||
vault/vault.log; do
|
||||
agent_name=$(basename "$(dirname "$log_entry")")
|
||||
log_path="${FACTORY_ROOT}/${log_entry}"
|
||||
if [ -f "$log_path" ]; then
|
||||
|
|
|
|||
|
|
@ -397,15 +397,10 @@
|
|||
<div class="role">Detects <strong>infrastructure patterns</strong> — recurring failures, resource trends, emerging issues. Files predictions for triage.</div>
|
||||
<div class="trigger">Cron: daily</div>
|
||||
</div>
|
||||
<div class="agent-card">
|
||||
<div class="name">action-agent</div>
|
||||
<div class="role">Executes <strong>operational tasks</strong> defined as formulas — site deployments, data migrations, any multi-step procedure.</div>
|
||||
<div class="trigger">Cron: every 5 min</div>
|
||||
</div>
|
||||
<div class="agent-card">
|
||||
<div class="name">vault</div>
|
||||
<div class="role"><strong>Safety gate.</strong> Reviews dangerous actions before they execute. Auto-approves safe operations, escalates risky ones to a human.</div>
|
||||
<div class="trigger">Event-driven</div>
|
||||
<div class="role"><strong>Being redesigned.</strong> Moving to PR-based approval workflow on ops repo. See issues #73-#77.</div>
|
||||
<div class="trigger">Redesign in progress</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -451,12 +446,11 @@
|
|||
|
||||
<!-- Vault -->
|
||||
<div class="section">
|
||||
<h2>Vault — quality gate</h2>
|
||||
<h2>Vault — being redesigned</h2>
|
||||
<div class="concept">
|
||||
<div class="label">How it works</div>
|
||||
<p>The vault sits between agents and dangerous actions. Before an agent can execute a risky operation (force push, deploy, delete), the vault reviews the request.</p>
|
||||
<p><strong>Auto-approve</strong> — safe, well-understood operations pass through instantly. <strong>Escalate</strong> — risky or novel operations get sent to a human via Matrix. <strong>Reject</strong> — clearly unsafe actions are blocked.</p>
|
||||
<p>You define the boundaries. The vault enforces them. This is what lets you sleep while the factory runs.</p>
|
||||
<div class="label">Redesign in progress</div>
|
||||
<p>The vault is being redesigned as a PR-based approval workflow on the ops repo. Instead of polling pending files, vault items will be created as PRs that require admin approval before execution.</p>
|
||||
<p><strong>See issues #73-#77</strong> for the design: #75 defines the vault.sh helper for creating vault PRs, #76 rewrites the dispatcher to poll for merged vault PRs, #77 adds branch protection requiring admin approval.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -524,8 +518,7 @@ disinto/
|
|||
├── <span class="agent-name">predictor/</span> predictor-run.sh (daily cron executor)
|
||||
├── <span class="agent-name">planner/</span> planner-run.sh (weekly cron executor)
|
||||
├── <span class="agent-name">supervisor/</span> supervisor-run.sh (health monitoring)
|
||||
├── <span class="agent-name">vault/</span> vault-poll.sh, vault-agent.sh, vault-fire.sh
|
||||
├── <span class="agent-name">action/</span> action-poll.sh, action-agent.sh
|
||||
├── <span class="agent-name">vault/</span> vault-env.sh (vault redesign in progress, see #73-#77)
|
||||
├── <span class="agent-name">lib/</span> env.sh, agent-session.sh, ci-helpers.sh
|
||||
├── <span class="agent-name">projects/</span> *.toml per-project config
|
||||
├── <span class="agent-name">formulas/</span> TOML specs for multi-step agent tasks
|
||||
|
|
|
|||
350
skill/SKILL.md
350
skill/SKILL.md
|
|
@ -1,350 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
#!/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}"
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
## 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
|
||||
|
|
@ -32,7 +32,6 @@ runs directly from cron like the planner and predictor.
|
|||
health-assessment, decide-actions, report, journal) with `needs` dependencies.
|
||||
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,
|
||||
disk, CI, git, dev-agent, review-agent, forge)
|
||||
- `supervisor/supervisor-poll.sh` — Legacy bash orchestrator (superseded by
|
||||
|
|
|
|||
|
|
@ -1,118 +0,0 @@
|
|||
# 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"
|
||||
```
|
||||
|
|
@ -132,8 +132,7 @@ echo ""
|
|||
|
||||
echo "## Recent Agent Logs"
|
||||
for _log in supervisor/supervisor.log dev/dev-agent.log review/review.log \
|
||||
gardener/gardener.log planner/planner.log predictor/predictor.log \
|
||||
action/action.log; do
|
||||
gardener/gardener.log planner/planner.log predictor/predictor.log; do
|
||||
_logpath="${FACTORY_ROOT}/${_log}"
|
||||
if [ -f "$_logpath" ]; then
|
||||
_log_age_min=$(( ($(date +%s) - $(stat -c %Y "$_logpath" 2>/dev/null || echo 0)) / 60 ))
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ source "$(dirname "$0")/../lib/ci-helpers.sh"
|
|||
LOGFILE="${DISINTO_LOG_DIR}/supervisor/supervisor.log"
|
||||
STATUSFILE="/tmp/supervisor-status"
|
||||
LOCKFILE="/tmp/supervisor-poll.lock"
|
||||
PROMPT_FILE="${FACTORY_ROOT}/supervisor/PROMPT.md"
|
||||
PROMPT_FILE="${FACTORY_ROOT}/formulas/run-supervisor.toml"
|
||||
PROJECTS_DIR="${FACTORY_ROOT}/projects"
|
||||
|
||||
METRICS_FILE="${DISINTO_LOG_DIR}/metrics/supervisor-metrics.jsonl"
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@ log "--- Supervisor run start ---"
|
|||
# ── Housekeeping: clean up stale crashed worktrees (>24h) ────────────────
|
||||
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 ────────────────────────────────────────────
|
||||
log "Running preflight.sh"
|
||||
PREFLIGHT_OUTPUT=""
|
||||
|
|
@ -68,9 +74,12 @@ else
|
|||
fi
|
||||
|
||||
# ── Load formula + context ───────────────────────────────────────────────
|
||||
load_formula "$FACTORY_ROOT/formulas/run-supervisor.toml"
|
||||
load_formula_or_profile "supervisor" "$FACTORY_ROOT/formulas/run-supervisor.toml" || exit 1
|
||||
build_context_block AGENTS.md
|
||||
|
||||
# ── Prepare .profile context (lessons injection) ─────────────────────────
|
||||
formula_prepare_profile_context
|
||||
|
||||
# ── Read scratch file (compaction survival) ───────────────────────────────
|
||||
SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
|
||||
SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
|
||||
|
|
@ -91,7 +100,7 @@ Fix what you can. File vault items for what you cannot. Do NOT ask permission
|
|||
${PREFLIGHT_OUTPUT}
|
||||
|
||||
## Project context
|
||||
${CONTEXT_BLOCK}
|
||||
${CONTEXT_BLOCK}$(formula_lessons_block)
|
||||
${SCRATCH_CONTEXT:+${SCRATCH_CONTEXT}
|
||||
}
|
||||
Priority order: P0 memory > P1 disk > P2 stopped > P3 degraded > P4 housekeeping
|
||||
|
|
@ -105,5 +114,8 @@ ${PROMPT_FOOTER}"
|
|||
agent_run --worktree "$WORKTREE" "$PROMPT"
|
||||
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"
|
||||
log "--- Supervisor run done ---"
|
||||
|
|
|
|||
748
tests/mock-forgejo.py
Executable file
748
tests/mock-forgejo.py
Executable file
|
|
@ -0,0 +1,748 @@
|
|||
#!/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()
|
||||
|
|
@ -1,32 +1,31 @@
|
|||
#!/usr/bin/env bash
|
||||
# tests/smoke-init.sh — End-to-end smoke test for disinto init
|
||||
# tests/smoke-init.sh — End-to-end smoke test for disinto init with mock Forgejo
|
||||
#
|
||||
# Expects a running Forgejo at SMOKE_FORGE_URL with a bootstrap admin
|
||||
# user already created (see .woodpecker/smoke-init.yml for CI setup).
|
||||
# Validates the full init flow: Forgejo API, user/token creation,
|
||||
# repo setup, labels, TOML generation, and cron installation.
|
||||
# Validates the full init flow using mock Forgejo server:
|
||||
# 1. Verify mock Forgejo is ready
|
||||
# 2. Set up mock binaries (docker, claude, tmux)
|
||||
# 3. Run disinto init
|
||||
# 4. Verify Forgejo state (users, repo)
|
||||
# 5. Verify local state (TOML, .env, repo clone)
|
||||
# 6. Verify cron setup
|
||||
#
|
||||
# Required env: SMOKE_FORGE_URL (default: http://localhost:3000)
|
||||
# Required env: FORGE_URL (default: http://localhost:3000)
|
||||
# Required tools: bash, curl, jq, python3, git
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
FACTORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
FORGE_URL="${SMOKE_FORGE_URL:-http://localhost:3000}"
|
||||
SETUP_ADMIN="setup-admin"
|
||||
SETUP_PASS="SetupPass-789xyz"
|
||||
TEST_SLUG="smoke-org/smoke-repo"
|
||||
FORGE_URL="${FORGE_URL:-http://localhost:3000}"
|
||||
MOCK_BIN="/tmp/smoke-mock-bin"
|
||||
MOCK_STATE="/tmp/smoke-mock-state"
|
||||
TEST_SLUG="smoke-org/smoke-repo"
|
||||
FAILED=0
|
||||
|
||||
fail() { printf 'FAIL: %s\n' "$*" >&2; FAILED=1; }
|
||||
pass() { printf 'PASS: %s\n' "$*"; }
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$MOCK_BIN" "$MOCK_STATE" /tmp/smoke-test-repo \
|
||||
"${FACTORY_ROOT}/projects/smoke-repo.toml" \
|
||||
"${FACTORY_ROOT}/docker-compose.yml"
|
||||
rm -rf "$MOCK_BIN" /tmp/smoke-test-repo \
|
||||
"${FACTORY_ROOT}/projects/smoke-repo.toml"
|
||||
# Restore .env only if we created the backup
|
||||
if [ -f "${FACTORY_ROOT}/.env.smoke-backup" ]; then
|
||||
mv "${FACTORY_ROOT}/.env.smoke-backup" "${FACTORY_ROOT}/.env"
|
||||
|
|
@ -40,11 +39,11 @@ trap cleanup EXIT
|
|||
if [ -f "${FACTORY_ROOT}/.env" ]; then
|
||||
cp "${FACTORY_ROOT}/.env" "${FACTORY_ROOT}/.env.smoke-backup"
|
||||
fi
|
||||
# Start with a clean .env (setup_forge writes tokens here)
|
||||
# Start with a clean .env
|
||||
printf '' > "${FACTORY_ROOT}/.env"
|
||||
|
||||
# ── 1. Verify Forgejo is ready ──────────────────────────────────────────────
|
||||
echo "=== 1/6 Verifying Forgejo at ${FORGE_URL} ==="
|
||||
# ── 1. Verify mock Forgejo is ready ─────────────────────────────────────────
|
||||
echo "=== 1/6 Verifying mock Forgejo at ${FORGE_URL} ==="
|
||||
retries=0
|
||||
api_version=""
|
||||
while true; do
|
||||
|
|
@ -55,163 +54,64 @@ while true; do
|
|||
fi
|
||||
retries=$((retries + 1))
|
||||
if [ "$retries" -gt 30 ]; then
|
||||
fail "Forgejo API not responding after 30s"
|
||||
fail "Mock Forgejo API not responding after 30s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
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
|
||||
pass "Mock Forgejo API v${api_version} (${retries}s)"
|
||||
|
||||
# ── 2. Set up mock binaries ─────────────────────────────────────────────────
|
||||
echo "=== 2/6 Setting up mock binaries ==="
|
||||
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"
|
||||
mkdir -p "$MOCK_BIN"
|
||||
|
||||
# ── Mock: docker ──
|
||||
# Routes 'docker exec' user-creation calls to the Forgejo admin API,
|
||||
# using the bootstrap admin's credentials.
|
||||
# Intercepts docker exec calls that disinto init --bare makes to Forgejo CLI
|
||||
cat > "$MOCK_BIN/docker" << 'DOCKERMOCK'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
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
|
||||
FORGE_URL="${SMOKE_FORGE_URL:-${FORGE_URL:-http://localhost:3000}}"
|
||||
if [ "${1:-}" = "ps" ]; then exit 0; fi
|
||||
if [ "${1:-}" = "exec" ]; then
|
||||
shift # remove 'exec'
|
||||
|
||||
# Skip docker exec flags (-u VALUE, -T, -i, etc.)
|
||||
shift
|
||||
while [ $# -gt 0 ] && [ "${1#-}" != "$1" ]; do
|
||||
case "$1" in
|
||||
-u|-w|-e) shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
case "$1" in -u|-w|-e) shift 2 ;; *) shift ;; esac
|
||||
done
|
||||
shift # remove container name (e.g. disinto-forgejo)
|
||||
|
||||
# $@ is now: forgejo admin user list|create [flags]
|
||||
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" = "list" ]; then echo "ID Username Email"; exit 0; fi
|
||||
if [ "$subcmd" = "create" ]; then
|
||||
shift 4 # skip 'forgejo admin user create'
|
||||
username="" password="" email="" is_admin="false"
|
||||
shift 4; username="" password="" email="" is_admin="false"
|
||||
while [ $# -gt 0 ]; do
|
||||
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 ;;
|
||||
--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
|
||||
|
||||
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" \
|
||||
curl -sf -X POST -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"
|
||||
-d "{\"username\":\"${username}\",\"password\":\"${password}\",\"email\":\"${email}\",\"must_change_password\":false}" >/dev/null 2>&1
|
||||
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" \
|
||||
curl -sf -X PATCH -H "Content-Type: application/json" \
|
||||
"${FORGE_URL}/api/v1/admin/users/${username}" \
|
||||
-d "${patch_body}" \
|
||||
>/dev/null 2>&1 || true
|
||||
|
||||
echo "New user '${username}' has been successfully created!"
|
||||
exit 0
|
||||
-d "{\"admin\":true,\"must_change_password\":false}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
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=""
|
||||
shift 4; username=""
|
||||
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
|
||||
case "$1" in --username) username="$2"; shift 2 ;; --password) shift 2 ;; --must-change-password*|--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" \
|
||||
curl -sf -X PATCH -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
|
||||
-d "{\"must_change_password\":false}" >/dev/null 2>&1 || true
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "mock-docker: unhandled exec: $*" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "mock-docker: unhandled command: $*" >&2
|
||||
exit 1
|
||||
DOCKERMOCK
|
||||
chmod +x "$MOCK_BIN/docker"
|
||||
|
|
@ -231,11 +131,8 @@ chmod +x "$MOCK_BIN/claude"
|
|||
printf '#!/usr/bin/env bash\nexit 0\n' > "$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"
|
||||
pass "Mock binaries installed (docker, claude, tmux)"
|
||||
pass "Mock binaries installed"
|
||||
|
||||
# ── 3. Run disinto init ─────────────────────────────────────────────────────
|
||||
echo "=== 3/6 Running disinto init ==="
|
||||
|
|
@ -245,9 +142,26 @@ rm -f "${FACTORY_ROOT}/projects/smoke-repo.toml"
|
|||
git config --global user.email "smoke@test.local"
|
||||
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 FORGE_URL
|
||||
|
||||
# Skip push to mock server (no git support)
|
||||
export SKIP_PUSH=true
|
||||
|
||||
if bash "${FACTORY_ROOT}/bin/disinto" init \
|
||||
"${TEST_SLUG}" \
|
||||
--bare --yes \
|
||||
|
|
@ -290,35 +204,6 @@ if [ "$repo_found" = false ]; then
|
|||
fail "Repo not found on Forgejo under any expected path"
|
||||
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 ───────────────────────────────────────────────────
|
||||
echo "=== 5/6 Verifying local state ==="
|
||||
|
||||
|
|
@ -357,7 +242,7 @@ else
|
|||
fail ".env not found"
|
||||
fi
|
||||
|
||||
# Repo was cloned
|
||||
# Repo was cloned (mock git repo created before disinto init)
|
||||
if [ -d "/tmp/smoke-test-repo/.git" ]; then
|
||||
pass "Repo cloned to /tmp/smoke-test-repo"
|
||||
else
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
<!-- 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
122
vault/PROMPT.md
|
|
@ -1,122 +0,0 @@
|
|||
# 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.
|
||||
81
vault/SCHEMA.md
Normal file
81
vault/SCHEMA.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# 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
|
||||
21
vault/examples/promote.toml
Normal file
21
vault/examples/promote.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# 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
|
||||
21
vault/examples/publish.toml
Normal file
21
vault/examples/publish.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# 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
|
||||
35
vault/examples/release.toml
Normal file
35
vault/examples/release.toml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# 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
|
||||
21
vault/examples/webhook-call.toml
Normal file
21
vault/examples/webhook-call.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# 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
|
||||
46
vault/validate.sh
Executable file
46
vault/validate.sh
Executable file
|
|
@ -0,0 +1,46 @@
|
|||
#!/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
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -7,3 +7,148 @@
|
|||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib/env.sh"
|
||||
# Use vault-bot's own Forgejo identity
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,141 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,301 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
#!/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"
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
#!/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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue