sync: scission owner/template + brain-template-export + BRAIN_MODE guard + /visualize scope filter + port orphelins fix

This commit is contained in:
2026-03-21 02:34:47 +01:00
parent 78323a0094
commit 2fd53cce8e
93 changed files with 6953 additions and 684 deletions

332
scripts/brain-state-bot.sh Executable file
View File

@@ -0,0 +1,332 @@
#!/bin/bash
# brain-state-bot.sh — tier free
# Lit les claims ouverts + git log → écrit/met à jour workspace/live-states.md
# Commit live-states.md avec "live-states: bot update"
#
# Usage : bash scripts/brain-state-bot.sh [--dry-run]
#
# Règles :
# - Ne ferme pas les claims BSI
# - Ne lit pas MYSECRETS
# - Silencieux sauf erreur critique (stderr)
# - Ne jamais écraser `needs` si déjà présent
set -uo pipefail
BRAIN_ROOT="${BRAIN_ROOT:-/home/tetardtek/Dev/Brain}"
LIVE_STATES="$BRAIN_ROOT/workspace/live-states.md"
DRY_RUN=0
# ─── Args ──────────────────────────────────────────────────────────────────
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=1 ;;
esac
done
# ─── Helpers ───────────────────────────────────────────────────────────────
_now_iso() {
date +"%Y-%m-%dT%H:%M"
}
# Convertit un timestamp ISO8601 (YYYY-MM-DDTHH:MM) en epoch seconds
_iso_to_epoch() {
local ts="$1"
# Remplacer T par espace pour date
date -d "${ts/T/ }" +%s 2>/dev/null || echo 0
}
# Extrait un champ YAML simple (key: value) depuis un fichier
_yaml_field() {
local file="$1" key="$2"
grep -E "^${key}:[[:space:]]" "$file" 2>/dev/null \
| head -1 \
| sed "s/^${key}:[[:space:]]*//" \
| tr -d '"' \
| xargs
}
# Dérive le slug projet depuis le filename du claim
# sess-YYYYMMDD-HHMM-slug1-slug2 → slug1 (premier segment après timestamp)
_derive_project() {
local sess_id="$1"
# Retirer "sess-YYYYMMDD-HHMM-" puis prendre le premier segment
local remainder
remainder=$(echo "$sess_id" | sed 's/^sess-[0-9]\{8\}-[0-9]\{4\}-//')
# Retirer suffixes connus (boot, brain, supervisor…) si présent après "-"
echo "$remainder" | cut -d'-' -f1
}
# Dérive le slug depuis le champ scope du claim
# scope: "originsdigital-back/" → "originsdigital"
# scope: "brain/" → "brain"
_project_from_scope() {
local scope="$1"
# Prendre le premier token, retirer trailing slash, puis garder partie avant "-"
local first_token
first_token=$(echo "$scope" | awk '{print $1}' | tr -d '/')
# Si contient "-", prendre la partie avant le dernier tiret
# ex: originsdigital-back → originsdigital
# ex: brain → brain
echo "$first_token" | sed 's/-[^-]*$//' | sed 's/\///'
}
# Cherche un repo git pour un slug projet
# Cherche dans Brain/, Gitea/, Github/ (insensible à la casse)
_find_project_repo() {
local slug="$1"
local candidates=(
"$BRAIN_ROOT"
"$BRAIN_ROOT/brain-ui"
"$BRAIN_ROOT/brain-engine"
"/home/tetardtek/Dev/Gitea"
"/home/tetardtek/Dev/Github"
)
# Match direct : brain → BRAIN_ROOT
if [ "$slug" = "brain" ]; then
echo "$BRAIN_ROOT"
return
fi
# Chercher un répertoire qui contient le slug (insensible à la casse)
for base in "${candidates[@]}"; do
[ -d "$base" ] || continue
# Vérifier si base lui-même match (ex: brain-ui)
local basename
basename=$(basename "$base" | tr '[:upper:]' '[:lower:]')
local slug_lc
slug_lc=$(echo "$slug" | tr '[:upper:]' '[:lower:]')
if [[ "$basename" == *"$slug_lc"* ]] && [ -d "$base/.git" ]; then
echo "$base"
return
fi
# Chercher sous-répertoires
if [ -d "$base" ]; then
local found
found=$(find "$base" -maxdepth 1 -type d -iname "*${slug}*" 2>/dev/null | head -1)
if [ -n "$found" ] && [ -d "$found/.git" ]; then
echo "$found"
return
fi
fi
done
echo ""
}
# Obtient le dernier commit message d'un repo
_git_last_commit() {
local repo="$1"
[ -d "$repo/.git" ] || { echo ""; return; }
git -C "$repo" log --oneline -1 2>/dev/null | sed 's/^[a-f0-9]* //' | head -c 80
}
# Obtient le timestamp du dernier commit (epoch)
_git_last_commit_epoch() {
local repo="$1"
[ -d "$repo/.git" ] || { echo "0"; return; }
git -C "$repo" log -1 --format="%ct" 2>/dev/null || echo "0"
}
# ─── Lecture de l'état courant de live-states.md ────────────────────────────
# Extrait un champ YAML d'une entrée de live-states.md identifiée par sess_id
# Retourne "" si le champ n'existe pas ou si le sess_id n'est pas trouvé
_get_existing_field() {
local sess_id="$1" field="$2"
local in_block=0 value=""
while IFS= read -r line; do
# Début de bloc : ligne "- sess_id: <id>"
if echo "$line" | grep -qE "^- sess_id:[[:space:]]*${sess_id}[[:space:]]*$"; then
in_block=1
continue
fi
# Fin de bloc : nouvelle entrée "- sess_id:" ou fin du fichier
if [ "$in_block" -eq 1 ]; then
if echo "$line" | grep -qE "^- sess_id:"; then
break
fi
# Lire le champ demandé
if echo "$line" | grep -qE "^[[:space:]]+${field}:[[:space:]]"; then
value=$(echo "$line" | sed "s/^[[:space:]]*${field}:[[:space:]]*//" | tr -d '"')
fi
fi
done < "$LIVE_STATES"
echo "$value"
}
# ─── Écriture d'un bloc dans live-states.md ─────────────────────────────────
# Met à jour ou insère un bloc sess_id dans live-states.md
# Args: sess_id project doing status needs priority updated
_upsert_block() {
local sess_id="$1"
local project="$2"
local doing="$3"
local status="$4"
local needs="$5"
local priority="$6"
local updated="$7"
local new_block
new_block="- sess_id: ${sess_id}
project: ${project}
doing: \"${doing}\"
status: ${status}
needs: ${needs}
priority: ${priority}
team: []
blocking: []
context: \"\"
updated: ${updated}"
if [ "$DRY_RUN" -eq 1 ]; then
echo "[dry-run] bloc à écrire pour ${sess_id}:"
echo "$new_block"
return
fi
# Vérifier si le bloc existe déjà
if grep -qE "^- sess_id:[[:space:]]*${sess_id}[[:space:]]*$" "$LIVE_STATES" 2>/dev/null; then
# Mise à jour différentielle : remplacer le bloc existant
# Utilise python3 pour éviter les conflits de syntaxe awk/bash
local tmpfile
tmpfile=$(mktemp)
python3 - "$LIVE_STATES" "$sess_id" "$new_block" > "$tmpfile" << 'PYEOF'
import sys, re
infile = sys.argv[1]
sess_id = sys.argv[2]
new_block = sys.argv[3]
with open(infile) as f:
lines = f.readlines()
out = []
in_block = False
for line in lines:
if re.match(r'^- sess_id:\s*' + re.escape(sess_id) + r'\s*$', line):
in_block = True
out.append(new_block + "\n")
continue
if in_block:
# Fin du bloc : nouvelle entrée, frontmatter ou commentaire niveau 0
if re.match(r'^- sess_id:', line) or re.match(r'^---', line) or re.match(r'^#', line):
in_block = False
out.append(line)
# else : ignorer les lignes de l'ancien bloc
else:
out.append(line)
sys.stdout.write("".join(out))
PYEOF
mv "$tmpfile" "$LIVE_STATES"
else
# Insertion : ajouter à la fin avec ligne vide de séparation
echo "" >> "$LIVE_STATES"
echo "$new_block" >> "$LIVE_STATES"
fi
}
# ─── Main ────────────────────────────────────────────────────────────────────
[ -f "$LIVE_STATES" ] || { echo "CRITICAL: $LIVE_STATES introuvable" >&2; exit 1; }
NOW_EPOCH=$(date +%s)
TWO_HOURS=7200
UPDATED=0 # Nombre de sessions mises à jour
for claim in "$BRAIN_ROOT/claims"/sess-*.yml; do
[ -f "$claim" ] || continue
# Lire les champs du claim
status=$(_yaml_field "$claim" "status")
[ "$status" = "open" ] || continue
sess_id=$(_yaml_field "$claim" "sess_id")
[ -n "$sess_id" ] || continue
scope=$(_yaml_field "$claim" "scope")
opened_at=$(_yaml_field "$claim" "opened_at")
# Dériver le projet depuis scope, puis depuis sess_id en fallback
project=""
if [ -n "$scope" ]; then
project=$(_project_from_scope "$scope")
fi
if [ -z "$project" ]; then
project=$(_derive_project "$sess_id")
fi
[ -n "$project" ] || project="unknown"
# Trouver le repo git du projet
repo=$(_find_project_repo "$project")
# Dériver doing depuis le dernier commit git
doing=""
if [ -n "$repo" ]; then
doing=$(_git_last_commit "$repo")
fi
[ -n "$doing" ] || doing="En cours"
# Récupérer l'état courant du bloc (si existant)
existing_needs=$(_get_existing_field "$sess_id" "needs")
existing_status=$(_get_existing_field "$sess_id" "status")
existing_updated=$(_get_existing_field "$sess_id" "updated")
# needs : ne jamais écraser si déjà présent
needs="${existing_needs:-none}"
# Si needs est vide string, mettre none
[ -n "$needs" ] || needs="none"
# Stale detection : si updated > 2h + status progressing + pas de commit récent
new_status="progressing"
if [ -n "$existing_status" ] && [ "$existing_status" != "closed" ]; then
new_status="$existing_status"
fi
if [ "$new_status" = "progressing" ]; then
# Vérifier si stale
stale=0
if [ -n "$existing_updated" ]; then
updated_epoch=$(_iso_to_epoch "$existing_updated")
age=$(( NOW_EPOCH - updated_epoch ))
if [ "$age" -gt "$TWO_HOURS" ]; then
# Pas de commit récent ?
last_commit_epoch=0
if [ -n "$repo" ]; then
last_commit_epoch=$(_git_last_commit_epoch "$repo")
fi
commit_age=$(( NOW_EPOCH - last_commit_epoch ))
if [ "$commit_age" -gt "$TWO_HOURS" ]; then
stale=1
fi
fi
fi
if [ "$stale" -eq 1 ]; then
new_status="idle"
echo "stale: ${sess_id} → idle" >&2
fi
fi
# Priority : medium par défaut (tier free — pas de blocking[] cross-claim)
priority="medium"
# Updated : maintenant
updated_ts=$(_now_iso)
_upsert_block "$sess_id" "$project" "$doing" "$new_status" "$needs" "$priority" "$updated_ts"
UPDATED=$(( UPDATED + 1 ))
done
# Commit si des sessions ont été mises à jour (et pas dry-run)
if [ "$DRY_RUN" -eq 0 ] && [ "$UPDATED" -gt 0 ]; then
git -C "$BRAIN_ROOT" add workspace/live-states.md 2>/dev/null
git -C "$BRAIN_ROOT" diff --cached --quiet 2>/dev/null || \
git -C "$BRAIN_ROOT" commit -m "live-states: bot update" 2>/dev/null
fi
exit 0