2026-04-06 17:57:17 +00:00
#!/usr/bin/env bash
# ops-setup.sh — Setup ops repository (disinto-ops)
#
# Source from bin/disinto:
# source "$(dirname "$0")/../lib/ops-setup.sh"
#
# Required globals: FORGE_URL, FORGE_TOKEN, FACTORY_ROOT
# Optional: admin_token (falls back to FORGE_TOKEN for admin operations)
#
# Functions:
# setup_ops_repo <forge_url> <ops_slug> <ops_root> [primary_branch]
# - Create ops repo on Forgejo if it doesn't exist
# - Configure bot collaborators with appropriate permissions
# - Clone or initialize ops repo locally
# - Seed directory structure (vault, knowledge, evidence)
# - Export _ACTUAL_OPS_SLUG for caller to use
2026-04-08 12:05:08 +00:00
# migrate_ops_repo <ops_root> [primary_branch]
# - Seed missing directories/files on existing ops repos (idempotent)
# - Creates .gitkeep files and template content for canonical structure
2026-04-06 17:57:17 +00:00
#
# Globals modified:
2026-04-08 12:05:08 +00:00
# _ACTUAL_OPS_SLUG - resolved ops repo slug after setup_ops_repo completes
2026-04-06 17:57:17 +00:00
set -euo pipefail
setup_ops_repo( ) {
local forge_url = " $1 " ops_slug = " $2 " ops_root = " $3 " primary_branch = " ${ 4 :- main } "
local org_name = " ${ ops_slug %%/* } "
local ops_name = " ${ ops_slug ##*/ } "
echo ""
echo "── Ops repo setup ─────────────────────────────────────"
# Determine the actual ops repo location by searching across possible namespaces
# This handles cases where the repo was created under a different namespace
# due to past bugs (e.g., dev-bot/disinto-ops instead of disinto-admin/disinto-ops)
local actual_ops_slug = ""
local -a possible_namespaces = ( " $org_name " "dev-bot" "disinto-admin" )
local http_code
for ns in " ${ possible_namespaces [@] } " ; do
slug = " ${ ns } / ${ ops_name } "
if curl -sf --max-time 5 \
-H " Authorization: token ${ FORGE_TOKEN } " \
" ${ forge_url } /api/v1/repos/ ${ slug } " >/dev/null 2>& 1; then
actual_ops_slug = " $slug "
echo " Ops repo: ${ slug } (found at ${ slug } ) "
break
fi
done
# If not found, try to create it in the configured namespace
if [ -z " $actual_ops_slug " ] ; then
echo " Creating ops repo in namespace: ${ org_name } "
# Create org if it doesn't exist
curl -sf -X POST \
-H " Authorization: token ${ admin_token :- ${ FORGE_TOKEN } } " \
-H "Content-Type: application/json" \
" ${ forge_url } /api/v1/orgs " \
-d " {\"username\":\" ${ org_name } \",\"visibility\":\"public\"} " >/dev/null 2>& 1 || true
if curl -sf -X POST \
-H " Authorization: token ${ admin_token :- ${ FORGE_TOKEN } } " \
-H "Content-Type: application/json" \
" ${ forge_url } /api/v1/orgs/ ${ org_name } /repos " \
-d " {\"name\":\" ${ ops_name } \",\"auto_init\":true,\"default_branch\":\" ${ primary_branch } \",\"description\":\"Operational data for ${ org_name } / ${ ops_name %-ops } \"} " >/dev/null 2>& 1; then
actual_ops_slug = " ${ org_name } / ${ ops_name } "
echo " Ops repo: ${ actual_ops_slug } created on Forgejo "
else
# Fallback: use admin API to create repo under the target namespace
http_code = $( curl -s -o /dev/null -w "%{http_code}" \
-X POST \
-H " Authorization: token ${ admin_token :- ${ FORGE_TOKEN } } " \
-H "Content-Type: application/json" \
" ${ forge_url } /api/v1/admin/users/ ${ org_name } /repos " \
-d " {\"name\":\" ${ ops_name } \",\"auto_init\":true,\"default_branch\":\" ${ primary_branch } \",\"description\":\"Operational data for ${ org_name } / ${ ops_name %-ops } \"} " 2>/dev/null || echo "0" )
if [ " $http_code " = "201" ] ; then
actual_ops_slug = " ${ org_name } / ${ ops_name } "
echo " Ops repo: ${ actual_ops_slug } created on Forgejo (via admin API) "
else
echo " Error: failed to create ops repo ' ${ org_name } / ${ ops_name } ' (HTTP ${ http_code } ) " >& 2
return 1
fi
fi
fi
# Configure collaborators on the ops repo
local bot_user bot_perm
declare -A bot_permissions = (
[ dev-bot] = "write"
[ review-bot] = "read"
[ planner-bot] = "write"
[ gardener-bot] = "write"
[ vault-bot] = "write"
[ supervisor-bot] = "read"
[ predictor-bot] = "read"
[ architect-bot] = "write"
)
# Add all bot users as collaborators with appropriate permissions
# vault branch protection (#77) requires:
# - Admin-only merge to main (enforced by admin_enforced: true)
# - Bots can push branches and create PRs, but cannot merge
for bot_user in " ${ !bot_permissions[@] } " ; do
bot_perm = " ${ bot_permissions [ $bot_user ] } "
if curl -sf -X PUT \
-H " Authorization: token ${ admin_token :- ${ FORGE_TOKEN } } " \
-H "Content-Type: application/json" \
" ${ forge_url } /api/v1/repos/ ${ actual_ops_slug } /collaborators/ ${ bot_user } " \
-d " {\"permission\":\" ${ bot_perm } \"} " >/dev/null 2>& 1; then
echo " + ${ bot_user } = ${ bot_perm } collaborator "
else
echo " ! ${ bot_user } = ${ bot_perm } (already set or failed) "
fi
done
# Add disinto-admin as admin collaborator
if curl -sf -X PUT \
-H " Authorization: token ${ admin_token :- ${ FORGE_TOKEN } } " \
-H "Content-Type: application/json" \
" ${ forge_url } /api/v1/repos/ ${ actual_ops_slug } /collaborators/disinto-admin " \
-d '{"permission":"admin"}' >/dev/null 2>& 1; then
echo " + disinto-admin = admin collaborator"
else
echo " ! disinto-admin = admin (already set or failed)"
fi
# Clone ops repo locally if not present
if [ ! -d " ${ ops_root } /.git " ] ; then
local auth_url
auth_url = $( printf '%s' " $forge_url " | sed " s|://|://dev-bot: ${ FORGE_TOKEN } @| " )
local clone_url = " ${ auth_url } / ${ actual_ops_slug } .git "
echo " Cloning: ops repo -> ${ ops_root } "
if git clone --quiet " $clone_url " " $ops_root " 2>/dev/null; then
echo " Ops repo: ${ actual_ops_slug } cloned successfully "
else
echo " Initializing: ops repo at ${ ops_root } "
mkdir -p " $ops_root "
git -C " $ops_root " init --initial-branch= " ${ primary_branch } " -q
# Set remote to the actual ops repo location
git -C " $ops_root " remote add origin " ${ forge_url } / ${ actual_ops_slug } .git "
echo " Ops repo: ${ actual_ops_slug } initialized locally "
fi
else
echo " Ops repo: ${ ops_root } (already exists locally) "
# Verify remote is correct
local current_remote
current_remote = $( git -C " $ops_root " remote get-url origin 2>/dev/null || true )
local expected_remote = " ${ forge_url } / ${ actual_ops_slug } .git "
if [ -n " $current_remote " ] && [ " $current_remote " != " $expected_remote " ] ; then
echo " Fixing: remote URL from ${ current_remote } to ${ expected_remote } "
git -C " $ops_root " remote set-url origin " $expected_remote "
fi
fi
# Seed directory structure
local seeded = false
mkdir -p " ${ ops_root } /vault/pending "
mkdir -p " ${ ops_root } /vault/approved "
mkdir -p " ${ ops_root } /vault/fired "
mkdir -p " ${ ops_root } /vault/rejected "
mkdir -p " ${ ops_root } /knowledge "
mkdir -p " ${ ops_root } /evidence/engagement "
2026-04-08 10:04:44 +00:00
mkdir -p " ${ ops_root } /evidence/red-team "
mkdir -p " ${ ops_root } /evidence/holdout "
mkdir -p " ${ ops_root } /evidence/evolution "
mkdir -p " ${ ops_root } /evidence/user-test "
2026-04-08 07:26:20 +00:00
mkdir -p " ${ ops_root } /sprints "
[ -f " ${ ops_root } /sprints/.gitkeep " ] || { touch " ${ ops_root } /sprints/.gitkeep " ; seeded = true; }
2026-04-08 10:04:44 +00:00
[ -f " ${ ops_root } /evidence/red-team/.gitkeep " ] || { touch " ${ ops_root } /evidence/red-team/.gitkeep " ; seeded = true; }
[ -f " ${ ops_root } /evidence/holdout/.gitkeep " ] || { touch " ${ ops_root } /evidence/holdout/.gitkeep " ; seeded = true; }
[ -f " ${ ops_root } /evidence/evolution/.gitkeep " ] || { touch " ${ ops_root } /evidence/evolution/.gitkeep " ; seeded = true; }
[ -f " ${ ops_root } /evidence/user-test/.gitkeep " ] || { touch " ${ ops_root } /evidence/user-test/.gitkeep " ; seeded = true; }
2026-04-06 17:57:17 +00:00
if [ ! -f " ${ ops_root } /README.md " ] ; then
cat > " ${ ops_root } /README.md " <<OPSEO F
# ${ops_name}
Operational data for the ${ ops_name %-ops } project.
## Structure
\` \` \`
${ ops_name } /
├── vault/
│ ├── pending/ # vault items awaiting approval
│ ├── approved/ # approved vault items
│ ├── fired/ # executed vault items
│ └── rejected/ # rejected vault items
2026-04-08 07:26:20 +00:00
├── sprints/ # sprint specs written by architect agent
2026-04-06 17:57:17 +00:00
├── knowledge/ # shared agent knowledge and best practices
├── evidence/ # engagement data, experiment results
├── portfolio.md # addressables + observables
├── prerequisites.md # dependency graph
└── RESOURCES.md # accounts, tokens (refs), infra inventory
\` \` \`
> **Note:** Journal directories ( journal/planner/ and journal/supervisor/) have been removed from the ops repo. Agent journals are now stored in each agent' s .profile repo on Forgejo.
## Branch protection
- \` main\` : 2 reviewers required for vault items
- Journal/evidence commits may use lighter rules
OPSEOF
seeded = true
fi
2026-04-08 19:35:33 +00:00
# Copy vault policy.toml template if not already present
if [ ! -f " ${ ops_root } /vault/policy.toml " ] ; then
local policy_src = " ${ FACTORY_ROOT :- $( cd " $( dirname " ${ BASH_SOURCE [0] } " ) /.. " && pwd ) } /vault/policy.toml "
if [ -f " $policy_src " ] ; then
cp " $policy_src " " ${ ops_root } /vault/policy.toml "
echo " + Copied vault/policy.toml template"
seeded = true
fi
fi
2026-04-06 17:57:17 +00:00
# Create stub files if they don't exist
[ -f " ${ ops_root } /portfolio.md " ] || { echo "# Portfolio" > " ${ ops_root } /portfolio.md " ; seeded = true; }
[ -f " ${ ops_root } /prerequisites.md " ] || { echo "# Prerequisite Tree" > " ${ ops_root } /prerequisites.md " ; seeded = true; }
[ -f " ${ ops_root } /RESOURCES.md " ] || { echo "# Resources" > " ${ ops_root } /RESOURCES.md " ; seeded = true; }
# Commit and push seed content
if [ " $seeded " = true ] && [ -d " ${ ops_root } /.git " ] ; then
# Auto-configure repo-local git identity if missing (#778)
if [ -z " $( git -C " $ops_root " config user.name 2>/dev/null) " ] ; then
git -C " $ops_root " config user.name "disinto-admin"
fi
if [ -z " $( git -C " $ops_root " config user.email 2>/dev/null) " ] ; then
git -C " $ops_root " config user.email "disinto-admin@localhost"
fi
git -C " $ops_root " add -A
if ! git -C " $ops_root " diff --cached --quiet 2>/dev/null; then
git -C " $ops_root " commit -m "chore: seed ops repo structure" -q
# Push if remote exists
if git -C " $ops_root " remote get-url origin >/dev/null 2>& 1; then
if git -C " $ops_root " push origin " ${ primary_branch } " -q 2>/dev/null; then
echo "Seeded: ops repo with initial structure"
else
echo "Warning: failed to push seed content to ops repo" >& 2
fi
fi
fi
fi
# Export resolved slug for the caller to write back to the project TOML
_ACTUAL_OPS_SLUG = " ${ actual_ops_slug } "
}
2026-04-08 12:05:08 +00:00
# migrate_ops_repo — Seed missing ops repo directories and files on existing deployments
#
# This function is idempotent — safe to run on every container start.
# It checks for missing directories/files and creates them with .gitkeep files
# or template content as appropriate.
#
# Called from entrypoint.sh after setup_ops_repo() to bring pre-#407 deployments
# up to date with the canonical ops repo structure.
migrate_ops_repo( ) {
local ops_root = " ${ 1 :- } "
local primary_branch = " ${ 2 :- main } "
# Validate ops_root argument
if [ -z " $ops_root " ] ; then
# Try to determine ops_root from environment or project config
if [ -n " ${ OPS_REPO_ROOT :- } " ] ; then
ops_root = " ${ OPS_REPO_ROOT } "
elif [ -n " ${ PROJECT_TOML :- } " ] && [ -f " $PROJECT_TOML " ] ; then
source " $( dirname " $0 " ) /load-project.sh " " $PROJECT_TOML "
ops_root = " ${ OPS_REPO_ROOT :- } "
fi
fi
# Skip if we still don't have an ops root
if [ -z " $ops_root " ] ; then
echo "migrate_ops_repo: skipping — no ops repo root determined"
return 0
fi
# Verify it's a git repo
if [ ! -d " ${ ops_root } /.git " ] ; then
echo " migrate_ops_repo: skipping — ${ ops_root } is not a git repo "
return 0
fi
echo ""
echo "── Ops repo migration ───────────────────────────────────"
echo " Checking ${ ops_root } for missing directories and files... "
local migrated = false
# Canonical ops repo structure (post #407)
# Directories to ensure exist with .gitkeep files
local -a dir_keepfiles = (
" ${ ops_root } /vault/pending/.gitkeep "
" ${ ops_root } /vault/approved/.gitkeep "
" ${ ops_root } /vault/fired/.gitkeep "
" ${ ops_root } /vault/rejected/.gitkeep "
" ${ ops_root } /knowledge/.gitkeep "
" ${ ops_root } /evidence/engagement/.gitkeep "
" ${ ops_root } /evidence/red-team/.gitkeep "
" ${ ops_root } /evidence/holdout/.gitkeep "
" ${ ops_root } /evidence/evolution/.gitkeep "
" ${ ops_root } /evidence/user-test/.gitkeep "
" ${ ops_root } /sprints/.gitkeep "
)
# Create missing directories and .gitkeep files
for keepfile in " ${ dir_keepfiles [@] } " ; do
local dir
dir = $( dirname " $keepfile " )
if [ ! -f " $keepfile " ] ; then
mkdir -p " $dir "
touch " $keepfile "
echo " + Created: ${ keepfile } "
migrated = true
fi
done
# Template files to create if missing (starter content)
local -a template_files = (
" ${ ops_root } /portfolio.md "
" ${ ops_root } /prerequisites.md "
" ${ ops_root } /RESOURCES.md "
)
for tfile in " ${ template_files [@] } " ; do
if [ ! -f " $tfile " ] ; then
local title
title = $( basename " $tfile " | sed 's/\.md$//; s/_/ /g' | sed 's/\b\(.\)/\u\1/g' )
{
echo " # ${ title } "
echo ""
echo "## Overview"
echo ""
echo "<!-- Add content here -->"
} > " $tfile "
echo " + Created: ${ tfile } "
migrated = true
fi
done
# Commit and push changes if any were made
if [ " $migrated " = true ] ; then
# Auto-configure repo-local git identity if missing
if [ -z " $( git -C " $ops_root " config user.name 2>/dev/null) " ] ; then
git -C " $ops_root " config user.name "disinto-admin"
fi
if [ -z " $( git -C " $ops_root " config user.email 2>/dev/null) " ] ; then
git -C " $ops_root " config user.email "disinto-admin@localhost"
fi
git -C " $ops_root " add -A
if ! git -C " $ops_root " diff --cached --quiet 2>/dev/null; then
git -C " $ops_root " commit -m "chore: migrate ops repo structure to canonical layout" -q
# Push if remote exists
if git -C " $ops_root " remote get-url origin >/dev/null 2>& 1; then
if git -C " $ops_root " push origin " ${ primary_branch } " -q 2>/dev/null; then
echo "Migrated: ops repo structure updated and pushed"
else
echo "Warning: failed to push migration to ops repo" >& 2
fi
fi
fi
else
echo " (all directories and files already present)"
fi
}