sync: scission owner/template + brain-template-export + BRAIN_MODE guard + /visualize scope filter + port orphelins fix
This commit is contained in:
332
scripts/brain-state-bot.sh
Executable file
332
scripts/brain-state-bot.sh
Executable 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
|
||||
Reference in New Issue
Block a user