feat(tools): add cockpit TUIs, ops scripts, CAD tooling, Mistral agents

Cockpit (54 scripts):
- Intelligence/agent TUIs, Mistral agents/studio TUI, e2e agent tests
- Mesh health, sync preflight, dirtyset cleanup, SSH healthcheck
- Mascarade runtime health, incident registry, dispatch mesh
- Machine registry, log ops, product contract audit
- Yiacad refonte/backend/proofs/operator TUIs
- Sentinelle cron, daily alignment, operator summaries
- Fab package, PCB AI fab, artifact WMS index TUIs

Ops:
- Deploy mascarade tower runtime
- Full operator lane sync

CAD:
- KiCad yiacad plugin, host MCP integration
- CAD stack scripts

Other:
- Mistral finetune tools
- Evals framework
- Mesh contract checker
- Updated AI scripts (zeroclaw, integrations)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
L'électron rare
2026-03-24 00:21:13 +01:00
parent 066acde0cb
commit 7d4ee627bf
114 changed files with 25969 additions and 177 deletions
+12
View File
@@ -26,9 +26,21 @@ bash tools/ai/zeroclaw_integrations_status.sh --json
bash tools/ai/zeroclaw_integrations_import_n8n.sh --json
```
Current runtime bootstrap:
- `bash tools/ai/zeroclaw_integrations_up.sh` auto-provisions the local `mascarade-n8n` container if Docker is running but the container is still missing.
- Official runtime basis: `docker.n8n.io/n8nio/n8n` on port `5678`, with a persistent volume per container name.
- Readiness is validated through `http://127.0.0.1:5678/healthz` in addition to the editor root URL, because first boot and editor startup can lag behind the health endpoint.
- Workflow activation path: reuse the tracked workflow when it is already active, otherwise prefer `n8n publish:workflow --id=<ID>` after import, with legacy fallback `n8n update:workflow --id=<ID> --active=true`.
- Activation proof now checks `n8n list:workflow --active=true --onlyId`, so `active=true` is no longer assumed.
- Runtime scripts fail fast on local Docker CLI timeouts and report a blocker instead of hanging indefinitely.
- If the local DB already contains the tracked workflow with the exact stored nodes/connections and `active=true`, the import script now skips the CLI path and returns success immediately.
- Recovery note: if local SQLite state is corrupted or still contains an obsolete workflow revision, preserve the volume backup and reset only the n8n DB files before reprovisioning the container.
Tracked smoke artifact:
- `tools/ai/integrations/n8n/kill_life_smoke_workflow.json`
- current trigger: yearly `Schedule Trigger`, so the workflow stays activable without creating noisy frequent runs
Historical workflow names:
@@ -4,10 +4,19 @@
"active": false,
"nodes": [
{
"parameters": {},
"id": "manual-trigger",
"name": "Manual Trigger",
"type": "n8n-nodes-base.manualTrigger",
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 0 0 1 1 *"
}
]
}
},
"id": "yearly-trigger",
"name": "Yearly Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1,
"position": [
260,
@@ -27,7 +36,7 @@
}
],
"connections": {
"Manual Trigger": {
"Yearly Trigger": {
"main": [
[
{
+2 -2
View File
@@ -4,8 +4,8 @@ set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="${ZEROCLAW_ROOT_DIR:-$(cd -- "$SCRIPT_DIR/../.." && pwd)}"
ZEROCLAW_BIN="${ZEROCLAW_BIN:-$ROOT_DIR/zeroclaw/target/release/zeroclaw}"
RTC_REPO="${ZEROCLAW_RTC_REPO:-$HOME/RTC_BL_PHONE}"
ZACUS_REPO="${ZEROCLAW_ZACUS_REPO:-$HOME/le-mystere-professeur-zacus}"
RTC_REPO="${ZEROCLAW_RTC_REPO:-$HOME/RTC_HW}"
ZACUS_REPO="${ZEROCLAW_ZACUS_REPO:-$HOME/ZACUS_HW}"
HARDWARE_ONLY=0
usage() {
+2 -2
View File
@@ -16,8 +16,8 @@ load_local_env
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="${ZEROCLAW_ROOT_DIR:-$(cd -- "$SCRIPT_DIR/../.." && pwd)}"
ZEROCLAW_BIN="${ZEROCLAW_BIN:-$ROOT_DIR/zeroclaw/target/release/zeroclaw}"
RTC_REPO="${ZEROCLAW_RTC_REPO:-$HOME/RTC_BL_PHONE}"
ZACUS_REPO="${ZEROCLAW_ZACUS_REPO:-$HOME/le-mystere-professeur-zacus}"
RTC_REPO="${ZEROCLAW_RTC_REPO:-$HOME/RTC_HW}"
ZACUS_REPO="${ZEROCLAW_ZACUS_REPO:-$HOME/ZACUS_HW}"
usage() {
cat <<USAGE
+2 -2
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
RTC_REPO="${ZEROCLAW_RTC_REPO:-$HOME/RTC_BL_PHONE}"
ZACUS_REPO="${ZEROCLAW_ZACUS_REPO:-$HOME/le-mystere-professeur-zacus}"
RTC_REPO="${ZEROCLAW_RTC_REPO:-$HOME/RTC_HW}"
ZACUS_REPO="${ZEROCLAW_ZACUS_REPO:-$HOME/ZACUS_HW}"
RTC_FW_DIR="$RTC_REPO"
ZACUS_FW_DIR="$ZACUS_REPO/hardware/firmware"
+20
View File
@@ -4,6 +4,20 @@ set -euo pipefail
N8N_CONTAINER="${ZEROCLAW_N8N_CONTAINER:-mascarade-n8n}"
YES=0
docker_available() {
command -v docker >/dev/null 2>&1 || return 1
if [[ -n "${DOCKER_HOST:-}" ]]; then
if [[ "$DOCKER_HOST" == unix://* ]]; then
local socket_path
socket_path="${DOCKER_HOST#unix://}"
[ -S "$socket_path" ] || return 1
fi
return 0
fi
[ -S "/Users/electron/.docker/run/docker.sock" ] || [ -S "/var/run/docker.sock" ] || [ -S "${HOME}/.docker/run/docker.sock" ] || return 1
}
usage() {
cat <<'EOF'
Usage: bash tools/ai/zeroclaw_integrations_down.sh [options]
@@ -42,6 +56,12 @@ done
exit 1
}
if ! docker_available; then
echo "already_stopped=${N8N_CONTAINER}"
echo "reason=docker_unavailable"
exit 0
fi
if docker ps --filter "name=^/${N8N_CONTAINER}$" --format '{{.Names}}' | grep -qx "${N8N_CONTAINER}"; then
docker stop "${N8N_CONTAINER}" >/dev/null
echo "stopped=${N8N_CONTAINER}"
+211 -40
View File
@@ -8,8 +8,59 @@ N8N_CONTAINER="${ZEROCLAW_N8N_CONTAINER:-mascarade-n8n}"
INPUT_FILE="${ZEROCLAW_N8N_WORKFLOW_FILE:-${ROOT_DIR}/tools/ai/integrations/n8n/kill_life_smoke_workflow.json}"
CONTAINER_INPUT_PATH="${ZEROCLAW_N8N_CONTAINER_INPUT:-/home/node/kill-life-import.json}"
PUBLISH=1
N8N_CLI_TIMEOUT_SECS="${ZEROCLAW_N8N_CLI_TIMEOUT_SECS:-45}"
DOCKER_TIMEOUT_SECS="${ZEROCLAW_N8N_DOCKER_TIMEOUT_SECS:-20}"
OUTPUT_JSON=0
emit_result() {
local import_action="$1"
local publish_action="$2"
local active_value="$3"
local reason_value="${4:-}"
if [[ "${OUTPUT_JSON}" == "1" ]]; then
ACTIVE="${active_value}" python3 - <<PY
import json
import os
payload = {
"workflow_id": ${workflow_id@Q},
"input_file": ${INPUT_FILE@Q},
"container": ${N8N_CONTAINER@Q},
"import_action": ${import_action@Q},
"publish_action": ${publish_action@Q},
"active": os.environ["ACTIVE"] == "true",
}
reason = ${reason_value@Q}
if reason:
payload["reason"] = reason
print(json.dumps(payload, ensure_ascii=True))
PY
return 0
fi
echo "workflow_id=${workflow_id}"
echo "input_file=${INPUT_FILE}"
echo "container=${N8N_CONTAINER}"
echo "import_action=${import_action}"
echo "publish_action=${publish_action}"
echo "active=${active_value}"
if [[ -n "${reason_value}" ]]; then
echo "reason=${reason_value}"
fi
}
docker_available() {
command -v docker >/dev/null 2>&1 || return 1
if [[ -n "${DOCKER_HOST:-}" ]]; then
if [[ "$DOCKER_HOST" == unix://* ]]; then
local socket_path
socket_path="${DOCKER_HOST#unix://}"
[ -S "$socket_path" ] || return 1
fi
return 0
fi
[ -S "/Users/electron/.docker/run/docker.sock" ] || [ -S "/var/run/docker.sock" ] || [ -S "${HOME}/.docker/run/docker.sock" ] || return 1
}
usage() {
cat <<'EOF'
Usage: bash tools/ai/zeroclaw_integrations_import_n8n.sh [options]
@@ -26,9 +77,106 @@ Env overrides:
ZEROCLAW_N8N_CONTAINER
ZEROCLAW_N8N_WORKFLOW_FILE
ZEROCLAW_N8N_CONTAINER_INPUT
ZEROCLAW_N8N_CLI_TIMEOUT_SECS
ZEROCLAW_N8N_DOCKER_TIMEOUT_SECS
EOF
}
docker_exec_timeout() {
python3 - "${N8N_CLI_TIMEOUT_SECS}" "$@" <<'PY'
import subprocess
import sys
timeout = float(sys.argv[1])
cmd = sys.argv[2:]
try:
proc = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout, check=False)
except subprocess.TimeoutExpired:
sys.stderr.write("cli_timeout\n")
raise SystemExit(124)
sys.stdout.write(proc.stdout)
sys.stderr.write(proc.stderr)
raise SystemExit(proc.returncode)
PY
}
docker_cmd_timeout() {
python3 - "${DOCKER_TIMEOUT_SECS}" "$@" <<'PY'
import subprocess
import sys
timeout = float(sys.argv[1])
cmd = sys.argv[2:]
try:
proc = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout, check=False)
except subprocess.TimeoutExpired:
sys.stderr.write("docker_timeout\n")
raise SystemExit(124)
sys.stdout.write(proc.stdout)
sys.stderr.write(proc.stderr)
raise SystemExit(proc.returncode)
PY
}
recent_activation_failure() {
local logs=""
if ! logs="$(docker_cmd_timeout docker logs --since 60s "${N8N_CONTAINER}" 2>&1 || true)"; then
return 1
fi
grep -Fq "Activation of workflow \"Kill LIFE n8n smoke\" (${workflow_id}) did fail" <<< "${logs}"
}
inspect_current_workflow_state() {
local temp_dir=""
temp_dir="$(mktemp -d)"
trap 'rm -rf "${temp_dir}"' RETURN
if ! docker_cmd_timeout docker cp "${N8N_CONTAINER}:/home/node/.n8n/database.sqlite" "${temp_dir}/database.sqlite" >/dev/null 2>/dev/null; then
return 1
fi
docker_cmd_timeout docker cp "${N8N_CONTAINER}:/home/node/.n8n/database.sqlite-wal" "${temp_dir}/database.sqlite-wal" >/dev/null 2>/dev/null || true
docker_cmd_timeout docker cp "${N8N_CONTAINER}:/home/node/.n8n/database.sqlite-shm" "${temp_dir}/database.sqlite-shm" >/dev/null 2>/dev/null || true
python3 - "${INPUT_FILE}" "${temp_dir}/database.sqlite" <<'PY'
import json
import sqlite3
import sys
from pathlib import Path
workflow_file = Path(sys.argv[1])
db_path = Path(sys.argv[2])
expected = json.loads(workflow_file.read_text(encoding="utf-8"))
conn = sqlite3.connect(str(db_path))
cur = conn.cursor()
row = cur.execute(
"select active, nodes, connections from workflow_entity where id = ?",
(expected["id"],),
).fetchone()
conn.close()
payload = {
"present": False,
"active": False,
"matches": False,
}
if row:
active, nodes_raw, connections_raw = row
payload["present"] = True
payload["active"] = bool(active)
try:
nodes = json.loads(nodes_raw or "[]")
connections = json.loads(connections_raw or "{}")
payload["matches"] = nodes == expected.get("nodes", []) and connections == expected.get("connections", {})
except json.JSONDecodeError:
payload["matches"] = False
print(json.dumps(payload, ensure_ascii=True))
PY
}
while [[ $# -gt 0 ]]; do
case "$1" in
--input)
@@ -76,46 +224,69 @@ PY
bash "${ROOT_DIR}/tools/ai/zeroclaw_integrations_up.sh" >/dev/null
existing_ids="$(docker exec "${N8N_CONTAINER}" n8n list:workflow --onlyId 2>/dev/null || true)"
import_action="skipped"
if ! printf '%s\n' "${existing_ids}" | grep -Fxq "${workflow_id}"; then
docker exec -i "${N8N_CONTAINER}" sh -lc "cat > '${CONTAINER_INPUT_PATH}'" < "${INPUT_FILE}"
docker exec "${N8N_CONTAINER}" n8n import:workflow --input="${CONTAINER_INPUT_PATH}" >/dev/null
import_action="imported"
fi
publish_action="skipped"
if [[ "${PUBLISH}" == "1" ]]; then
docker exec "${N8N_CONTAINER}" n8n publish:workflow --id="${workflow_id}" >/dev/null
publish_action="published"
fi
active_ids="$(docker exec "${N8N_CONTAINER}" n8n list:workflow --active=true --onlyId 2>/dev/null || true)"
is_active=false
if printf '%s\n' "${active_ids}" | grep -Fxq "${workflow_id}"; then
is_active=true
fi
if [[ "${OUTPUT_JSON}" == "1" ]]; then
ACTIVE="${is_active}" python3 - <<PY
import json
import os
print(json.dumps({
"workflow_id": ${workflow_id@Q},
"input_file": ${INPUT_FILE@Q},
"container": ${N8N_CONTAINER@Q},
"import_action": ${import_action@Q},
"publish_action": ${publish_action@Q},
"active": os.environ["ACTIVE"] == "true",
}, ensure_ascii=True))
PY
if ! docker_available; then
emit_result "skipped" "skipped" "false" "docker_unavailable"
exit 0
fi
echo "workflow_id=${workflow_id}"
echo "input_file=${INPUT_FILE}"
echo "container=${N8N_CONTAINER}"
echo "import_action=${import_action}"
echo "publish_action=${publish_action}"
echo "active=${is_active}"
workflow_state_json="$(inspect_current_workflow_state || true)"
if [[ -n "${workflow_state_json}" ]]; then
if python3 - "${workflow_state_json}" "${PUBLISH}" <<'PY'
import json
import sys
state = json.loads(sys.argv[1])
publish = sys.argv[2] == "1"
ok = state.get("present") and state.get("matches") and (state.get("active") or not publish)
raise SystemExit(0 if ok else 1)
PY
then
active_value="false"
if python3 - "${workflow_state_json}" <<'PY'
import json
import sys
state = json.loads(sys.argv[1])
raise SystemExit(0 if state.get("active") else 1)
PY
then
active_value="true"
fi
emit_result "skipped" "skipped" "${active_value}"
exit 0
fi
fi
docker_cmd_timeout docker cp "${INPUT_FILE}" "${N8N_CONTAINER}:${CONTAINER_INPUT_PATH}" >/dev/null
import_action="skipped"
publish_action="skipped"
is_active=false
docker_exec_timeout docker exec -u node "${N8N_CONTAINER}" n8n import:workflow --input="${CONTAINER_INPUT_PATH}" >/dev/null
import_action="imported"
if [[ "${PUBLISH}" == "1" ]]; then
if docker_exec_timeout docker exec -u node "${N8N_CONTAINER}" n8n publish:workflow --id="${workflow_id}" >/dev/null; then
publish_action="published"
elif docker_exec_timeout docker exec -u node "${N8N_CONTAINER}" n8n update:workflow --id="${workflow_id}" --active=true >/dev/null; then
publish_action="activated"
else
publish_action="failed"
fi
fi
if [[ "${PUBLISH}" == "1" ]]; then
if ! recent_activation_failure; then
is_active=true
fi
else
is_active=true
fi
if [[ "${PUBLISH}" == "1" && "${is_active}" != "true" ]]; then
if [[ "${publish_action}" == "skipped" ]]; then
publish_action="failed"
fi
emit_result "${import_action}" "${publish_action}" "${is_active}" "workflow_not_active_after_publish"
exit 1
fi
emit_result "${import_action}" "${publish_action}" "${is_active}"
+113 -12
View File
@@ -45,6 +45,7 @@ tools/ai/zeroclaw_integrations_status.sh
tools/ai/zeroclaw_integrations_up.sh
tools/cockpit/README.md
tools/cockpit/lot_chain.sh
tools/cockpit/run_next_lots_autonomously.sh
ai-agentic-embedded-base/specs/03_plan.md
ai-agentic-embedded-base/specs/04_tasks.md
ai-agentic-embedded-base/specs/README.md
@@ -62,6 +63,7 @@ status_text() {
printf 'dirty_status:\n'
git -C "$ROOT_DIR" status --short -- "${paths[@]}" || true
printf '\ncanonical_commands:\n'
printf -- '- bash tools/ai/zeroclaw_integrations_status.sh --json\n'
printf -- '- bash tools/ai/zeroclaw_integrations_lot.sh verify\n'
printf -- '- bash tools/run_autonomous_next_lots.sh run\n'
printf -- '- bash tools/cockpit/lot_chain.sh all --yes\n'
@@ -87,6 +89,7 @@ print(json.dumps({
"paths": json.loads(os.environ["PATHS_JSON"]),
"dirty_status": json.loads(os.environ["DIRTY_JSON"]),
"canonical_commands": [
"bash tools/ai/zeroclaw_integrations_status.sh --json",
"bash tools/ai/zeroclaw_integrations_lot.sh verify",
"bash tools/run_autonomous_next_lots.sh run",
"bash tools/cockpit/lot_chain.sh all --yes",
@@ -100,6 +103,10 @@ run_verify() {
local syntax_ok=0
local status_json_output=""
local import_json_output=""
local verify_output=""
local verify_rc=0
local status_rc=0
local import_rc=0
syntax_cmd=(
bash -n
@@ -122,30 +129,124 @@ run_verify() {
"${syntax_cmd[@]}"
syntax_ok=1
status_json_output="$("${status_cmd[@]}")"
import_json_output="$("${import_cmd[@]}")"
if ! status_json_output="$("${status_cmd[@]}" 2>&1)"; then
status_rc=$?
fi
status_json_output="$(
RAW_OUTPUT="${status_json_output}" STAGE_NAME="status" python3 - <<'PY'
import json
import os
if [[ "$OUTPUT_JSON" == "1" ]]; then
SYNTAX_OK="$syntax_ok" \
raw = os.environ["RAW_OUTPUT"]
stage = os.environ["STAGE_NAME"]
try:
payload = json.loads(raw)
except json.JSONDecodeError:
payload = {
"status": "blocked",
"reason": raw.strip() or f"{stage} command failed",
"stage": stage,
}
print(json.dumps(payload, ensure_ascii=True))
PY
)"
if [[ "${status_rc}" -eq 0 ]]; then
if ! import_json_output="$("${import_cmd[@]}" 2>&1)"; then
import_rc=$?
fi
else
import_rc=1
import_json_output='{"workflow_id":"kill-life-n8n-smoke","import_action":"skipped","publish_action":"skipped","active":false,"reason":"status_failed"}'
fi
import_json_output="$(
RAW_OUTPUT="${import_json_output}" STAGE_NAME="import" python3 - <<'PY'
import json
import os
raw = os.environ["RAW_OUTPUT"]
stage = os.environ["STAGE_NAME"]
try:
payload = json.loads(raw)
except json.JSONDecodeError:
payload = {
"workflow_id": "kill-life-n8n-smoke",
"import_action": "failed",
"publish_action": "failed",
"active": False,
"reason": raw.strip() or f"{stage} command failed",
"stage": stage,
}
print(json.dumps(payload, ensure_ascii=True))
PY
)"
verify_output="$(
STATUS_JSON="${status_json_output}" \
IMPORT_JSON="${import_json_output}" \
python3 - <<'PY'
import json
import os
print(json.dumps({
status = json.loads(os.environ["STATUS_JSON"])
workflow = json.loads(os.environ["IMPORT_JSON"])
blockers = []
overall = "ready"
status_state = str(status.get("status", "")).strip().lower()
if status_state in {"blocked", "degraded"}:
overall = "blocked"
blockers.append(str(status.get("reason", status_state)))
else:
if not status.get("container_running", False):
overall = "blocked"
blockers.append("n8n container not running")
if not status.get("internal_http_ok", False):
overall = "blocked"
blockers.append("n8n internal health probe failed")
if not status.get("host_http_ok", False):
overall = "blocked"
blockers.append("n8n host HTTP probe failed")
if not workflow.get("active", False):
overall = "blocked"
blockers.append("tracked smoke workflow is not active")
if workflow.get("reason"):
overall = "blocked"
blockers.append(str(workflow["reason"]))
payload = {
"lot_id": "zeroclaw-integrations-n8n",
"syntax_ok": os.environ["SYNTAX_OK"] == "1",
"status": json.loads(os.environ["STATUS_JSON"]),
"import": json.loads(os.environ["IMPORT_JSON"]),
}, ensure_ascii=True))
"syntax_ok": True,
"overall_status": overall,
"blockers": list(dict.fromkeys(blockers)),
"status": status,
"import": workflow,
}
print(json.dumps(payload, ensure_ascii=True))
PY
return 0
)"
if [[ "${status_rc}" -ne 0 || "${import_rc}" -ne 0 ]]; then
verify_rc=1
elif ! python3 - <<'PY' "${verify_output}"
import json
import sys
payload = json.loads(sys.argv[1])
raise SystemExit(0 if payload.get("overall_status") == "ready" else 1)
PY
then
verify_rc=1
fi
if [[ "$OUTPUT_JSON" == "1" ]]; then
printf '%s\n' "${verify_output}"
return "${verify_rc}"
fi
printf 'syntax_ok=true\n'
printf 'status=%s\n' "${status_json_output}"
printf 'import=%s\n' "${import_json_output}"
printf 'verify=%s\n' "${verify_output}"
return "${verify_rc}"
}
if [[ $# -gt 0 ]]; then
+141 -15
View File
@@ -3,7 +3,69 @@ set -euo pipefail
N8N_CONTAINER="${ZEROCLAW_N8N_CONTAINER:-mascarade-n8n}"
N8N_URL="${ZEROCLAW_N8N_URL:-http://127.0.0.1:5678/}"
N8N_HEALTH_URL="${ZEROCLAW_N8N_HEALTH_URL:-${N8N_URL%/}/healthz}"
OUTPUT_JSON=0
TRACKED_WORKFLOW_ID="${ZEROCLAW_N8N_WORKFLOW_ID:-kill-life-n8n-smoke}"
DOCKER_TIMEOUT_SECS="${ZEROCLAW_N8N_DOCKER_TIMEOUT_SECS:-10}"
STATUS_VALUE="blocked"
STATUS_REASON="n8n_http_unreachable"
RUNTIME_ALERTS_CSV=""
docker_available() {
command -v docker >/dev/null 2>&1 || return 1
if [[ -n "${DOCKER_HOST:-}" ]]; then
if [[ "$DOCKER_HOST" == unix://* ]]; then
local socket_path
socket_path="${DOCKER_HOST#unix://}"
[ -S "$socket_path" ] || return 1
fi
return 0
fi
[ -S "/Users/electron/.docker/run/docker.sock" ] || [ -S "/var/run/docker.sock" ] || [ -S "${HOME}/.docker/run/docker.sock" ] || return 1
}
docker_cmd_timeout() {
python3 - "${DOCKER_TIMEOUT_SECS}" "$@" <<'PY'
import subprocess
import sys
timeout = float(sys.argv[1])
cmd = sys.argv[2:]
try:
proc = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout, check=False)
except subprocess.TimeoutExpired:
sys.stderr.write("docker_timeout\n")
raise SystemExit(124)
sys.stdout.write(proc.stdout)
sys.stderr.write(proc.stderr)
raise SystemExit(proc.returncode)
PY
}
probe_host_http() {
curl --max-time 5 -fsSI "${N8N_URL}" >/dev/null 2>&1 || \
curl --max-time 5 -fsS "${N8N_HEALTH_URL}" >/dev/null 2>&1
}
collect_runtime_alerts() {
local logs=""
local -a alerts=()
if ! logs="$(docker_cmd_timeout docker logs --tail 120 "${N8N_CONTAINER}" 2>&1 || true)"; then
return 0
fi
if grep -Fq "Database connection timed out" <<< "${logs}"; then
alerts+=("n8n_database_timeout")
fi
if grep -Fq "has no node to start the workflow" <<< "${logs}"; then
alerts+=("workflow_activation_invalid")
fi
printf '%s\n' "${alerts[@]}"
}
usage() {
cat <<'EOF'
@@ -18,6 +80,9 @@ Options:
Env overrides:
ZEROCLAW_N8N_CONTAINER
ZEROCLAW_N8N_URL
ZEROCLAW_N8N_HEALTH_URL
ZEROCLAW_N8N_WORKFLOW_ID
ZEROCLAW_N8N_DOCKER_TIMEOUT_SECS
EOF
}
@@ -39,8 +104,6 @@ while [[ $# -gt 0 ]]; do
shift
done
container_line="$(docker ps -a --filter "name=^/${N8N_CONTAINER}$" --format '{{.Names}}|{{.Status}}' | head -n 1)"
running_line="$(docker ps --filter "name=^/${N8N_CONTAINER}$" --format '{{.Names}}|{{.Status}}' | head -n 1)"
container_exists=false
container_running=false
container_status="missing"
@@ -48,27 +111,76 @@ internal_http_ok=false
host_http_ok=false
workflow_ids=""
active_ids=""
docker_unavailable=0
workflow_probe_status="not_queried"
active_probe_status="not_queried"
if [[ -n "${container_line}" ]]; then
if docker_available; then
if ! container_line="$(docker_cmd_timeout docker ps -a --filter "name=^/${N8N_CONTAINER}$" --format '{{.Names}}|{{.Status}}' 2>/dev/null | head -n 1)"; then
container_status="docker cli timeout"
container_line=""
fi
if ! running_line="$(docker_cmd_timeout docker ps --filter "name=^/${N8N_CONTAINER}$" --format '{{.Names}}|{{.Status}}' 2>/dev/null | head -n 1)"; then
container_status="docker cli timeout"
running_line=""
fi
if [[ "${container_status}" != "docker cli timeout" && -n "${container_line}" ]]; then
container_exists=true
container_status="${container_line#*|}"
fi
if [[ "${container_status}" != "docker cli timeout" && -n "${running_line}" ]]; then
container_running=true
fi
else
container_exists=false
container_running=false
container_status="docker unavailable"
docker_unavailable=1
fi
if probe_host_http; then
host_http_ok=true
internal_http_ok=true
container_exists=true
container_status="${container_line#*|}"
fi
if [[ -n "${running_line}" ]]; then
container_running=true
if [[ "${container_status}" == "missing" ]]; then
container_status="host http ok (container unverified)"
elif [[ "${container_status}" == "docker cli timeout" ]]; then
container_status="docker cli timeout (host http ok)"
elif [[ "${container_status}" == "docker unavailable" ]]; then
container_status="docker unavailable (host http ok)"
fi
fi
if [[ "${container_running}" == true ]]; then
if docker exec "${N8N_CONTAINER}" sh -lc 'which wget >/dev/null 2>&1 && wget -q -O /dev/null http://127.0.0.1:5678/'; then
internal_http_ok=true
if [[ "${host_http_ok}" == true ]]; then
if [[ "${container_status}" == docker\ cli\ timeout* || "${container_status}" == docker\ unavailable* ]]; then
STATUS_VALUE="degraded"
STATUS_REASON="runtime_reachable_docker_unavailable"
else
STATUS_VALUE="ready"
STATUS_REASON=""
fi
elif [[ "${container_status}" == "docker cli timeout" ]]; then
STATUS_VALUE="blocked"
STATUS_REASON="docker_cli_timeout"
elif [[ "${docker_unavailable}" == "1" ]]; then
STATUS_VALUE="degraded"
STATUS_REASON="docker_unavailable"
fi
if curl -fsSI "${N8N_URL}" >/dev/null 2>&1; then
host_http_ok=true
if [[ "${host_http_ok}" != true && "${container_running}" == true ]]; then
RUNTIME_ALERTS_CSV="$(
collect_runtime_alerts \
| python3 -c 'import json,sys; items=[line.strip() for line in sys.stdin if line.strip()]; print(",".join(items))'
)"
if [[ ",${RUNTIME_ALERTS_CSV}," == *",n8n_database_timeout,"* ]]; then
STATUS_REASON="n8n_database_timeout"
elif [[ ",${RUNTIME_ALERTS_CSV}," == *",workflow_activation_invalid,"* ]]; then
STATUS_REASON="workflow_activation_invalid"
fi
workflow_ids="$(docker exec "${N8N_CONTAINER}" n8n list:workflow --onlyId 2>/dev/null | sed '/^$/d' | paste -sd, - || true)"
active_ids="$(docker exec "${N8N_CONTAINER}" n8n list:workflow --active=true --onlyId 2>/dev/null | sed '/^$/d' | paste -sd, - || true)"
fi
if [[ "${OUTPUT_JSON}" == "1" ]]; then
@@ -80,6 +192,8 @@ if [[ "${OUTPUT_JSON}" == "1" ]]; then
import json
import os
print(json.dumps({
"status": ${STATUS_VALUE@Q},
"reason": ${STATUS_REASON@Q},
"container": ${N8N_CONTAINER@Q},
"container_exists": os.environ["CONTAINER_EXISTS"] == "true",
"container_running": os.environ["CONTAINER_RUNNING"] == "true",
@@ -87,6 +201,11 @@ print(json.dumps({
"internal_http_ok": os.environ["INTERNAL_HTTP_OK"] == "true",
"host_http_ok": os.environ["HOST_HTTP_OK"] == "true",
"n8n_url": ${N8N_URL@Q},
"n8n_health_url": ${N8N_HEALTH_URL@Q},
"tracked_workflow_id": ${TRACKED_WORKFLOW_ID@Q},
"runtime_alerts": [item for item in ${RUNTIME_ALERTS_CSV@Q}.split(",") if item],
"workflow_probe_status": ${workflow_probe_status@Q},
"active_probe_status": ${active_probe_status@Q},
"workflow_ids": [item for item in ${workflow_ids@Q}.split(",") if item],
"active_workflow_ids": [item for item in ${active_ids@Q}.split(",") if item],
}, ensure_ascii=True))
@@ -98,8 +217,15 @@ echo "container=${N8N_CONTAINER}"
echo "exists=${container_exists}"
echo "running=${container_running}"
echo "status=${container_status}"
echo "overall_status=${STATUS_VALUE}"
echo "reason=${STATUS_REASON:-"(none)"}"
echo "internal_http_ok=${internal_http_ok}"
echo "host_http_ok=${host_http_ok}"
echo "n8n_url=${N8N_URL}"
echo "n8n_health_url=${N8N_HEALTH_URL}"
echo "runtime_alerts=${RUNTIME_ALERTS_CSV:-"(none)"}"
echo "tracked_workflow_id=${TRACKED_WORKFLOW_ID}"
echo "workflow_probe_status=${workflow_probe_status}"
echo "active_probe_status=${active_probe_status}"
echo "workflow_ids=${workflow_ids:-"(none)"}"
echo "active_workflow_ids=${active_ids:-"(none)"}"
+149 -8
View File
@@ -6,8 +6,80 @@ ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
N8N_CONTAINER="${ZEROCLAW_N8N_CONTAINER:-mascarade-n8n}"
WAIT_SECS="${ZEROCLAW_N8N_WAIT_SECS:-30}"
N8N_IMAGE="${ZEROCLAW_N8N_IMAGE:-docker.n8n.io/n8nio/n8n}"
N8N_VOLUME="${ZEROCLAW_N8N_VOLUME:-${N8N_CONTAINER}-data}"
N8N_PORT="${ZEROCLAW_N8N_PORT:-5678}"
N8N_URL="${ZEROCLAW_N8N_URL:-http://127.0.0.1:${N8N_PORT}/}"
N8N_HEALTH_URL="${ZEROCLAW_N8N_HEALTH_URL:-${N8N_URL%/}/healthz}"
N8N_TIMEZONE="${ZEROCLAW_N8N_TIMEZONE:-${TZ:-Europe/Paris}}"
AUTO_PROVISION="${ZEROCLAW_N8N_AUTOPROVISION:-1}"
DOCKER_TIMEOUT_SECS="${ZEROCLAW_N8N_DOCKER_TIMEOUT_SECS:-20}"
OUTPUT_JSON=0
emit_runtime_json() {
local status_value="$1"
local reason_value="$2"
python3 - <<PY
import json
print(json.dumps({
"status": ${status_value@Q},
"reason": ${reason_value@Q},
"container": ${N8N_CONTAINER@Q},
"container_state": "unverified",
"n8n_url": ${N8N_URL@Q},
"n8n_health_url": ${N8N_HEALTH_URL@Q},
}, ensure_ascii=True))
PY
}
probe_http_ready() {
curl --max-time 5 -fsSI "${N8N_URL}" >/dev/null 2>&1 || \
curl --max-time 5 -fsS "${N8N_HEALTH_URL}" >/dev/null 2>&1
}
emit_runtime_error() {
local reason_value="$1"
if [[ "${OUTPUT_JSON}" == "1" ]]; then
emit_runtime_json "blocked" "${reason_value}"
exit 0
fi
echo "${reason_value}" >&2
exit 1
}
docker_available() {
command -v docker >/dev/null 2>&1 || return 1
if [[ -n "${DOCKER_HOST:-}" ]]; then
if [[ "$DOCKER_HOST" == unix://* ]]; then
local socket_path
socket_path="${DOCKER_HOST#unix://}"
[ -S "$socket_path" ] || return 1
fi
return 0
fi
[ -S "/Users/electron/.docker/run/docker.sock" ] || [ -S "/var/run/docker.sock" ] || [ -S "${HOME}/.docker/run/docker.sock" ] || return 1
}
docker_cmd_timeout() {
python3 - "${DOCKER_TIMEOUT_SECS}" "$@" <<'PY'
import subprocess
import sys
timeout = float(sys.argv[1])
cmd = sys.argv[2:]
try:
proc = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout, check=False)
except subprocess.TimeoutExpired:
sys.stderr.write("docker_timeout\n")
raise SystemExit(124)
sys.stdout.write(proc.stdout)
sys.stderr.write(proc.stderr)
raise SystemExit(proc.returncode)
PY
}
usage() {
cat <<'EOF'
Usage: bash tools/ai/zeroclaw_integrations_up.sh [options]
@@ -21,6 +93,14 @@ Options:
Env overrides:
ZEROCLAW_N8N_CONTAINER
ZEROCLAW_N8N_WAIT_SECS
ZEROCLAW_N8N_IMAGE
ZEROCLAW_N8N_VOLUME
ZEROCLAW_N8N_PORT
ZEROCLAW_N8N_URL
ZEROCLAW_N8N_HEALTH_URL
ZEROCLAW_N8N_TIMEZONE
ZEROCLAW_N8N_AUTOPROVISION
ZEROCLAW_N8N_DOCKER_TIMEOUT_SECS
EOF
}
@@ -42,22 +122,78 @@ while [[ $# -gt 0 ]]; do
shift
done
running_line="$(docker ps --filter "name=^/${N8N_CONTAINER}$" --format '{{.Names}}' | head -n 1)"
existing_line="$(docker ps -a --filter "name=^/${N8N_CONTAINER}$" --format '{{.Names}}' | head -n 1)"
if ! docker_available; then
if probe_http_ready; then
if [[ "${OUTPUT_JSON}" == "1" ]]; then
bash "${ROOT_DIR}/tools/ai/zeroclaw_integrations_status.sh" --json
else
bash "${ROOT_DIR}/tools/ai/zeroclaw_integrations_status.sh"
fi
exit 0
fi
if [[ "${OUTPUT_JSON}" == "1" ]]; then
emit_runtime_json "degraded" "docker_unavailable"
exit 0
fi
echo "status=degraded"
echo "container=${N8N_CONTAINER}"
echo "container_state=skipped"
echo "reason=docker_unavailable"
exit 0
fi
if probe_http_ready; then
if [[ "${OUTPUT_JSON}" == "1" ]]; then
bash "${ROOT_DIR}/tools/ai/zeroclaw_integrations_status.sh" --json
else
bash "${ROOT_DIR}/tools/ai/zeroclaw_integrations_status.sh"
fi
exit 0
fi
if ! running_line="$(docker_cmd_timeout docker ps --filter "name=^/${N8N_CONTAINER}$" --format '{{.Names}}' 2>/dev/null | head -n 1)"; then
emit_runtime_error "docker_cli_timeout"
fi
if ! existing_line="$(docker_cmd_timeout docker ps -a --filter "name=^/${N8N_CONTAINER}$" --format '{{.Names}}' 2>/dev/null | head -n 1)"; then
emit_runtime_error "docker_cli_timeout"
fi
if [[ -n "${running_line}" ]]; then
:
if ! probe_http_ready; then
if ! docker_cmd_timeout docker restart "${N8N_CONTAINER}" >/dev/null 2>/dev/null; then
emit_runtime_error "docker_cli_timeout"
fi
fi
elif [[ -n "${existing_line}" ]]; then
docker start "${N8N_CONTAINER}" >/dev/null
if ! docker_cmd_timeout docker start "${N8N_CONTAINER}" >/dev/null 2>/dev/null; then
emit_runtime_error "docker_cli_timeout"
fi
else
echo "n8n container not found: ${N8N_CONTAINER}" >&2
echo "Expected an already-provisioned companion runtime (for example mascarade-n8n)." >&2
exit 1
if [[ "${AUTO_PROVISION}" != "1" ]]; then
echo "n8n container not found: ${N8N_CONTAINER}" >&2
echo "Expected an already-provisioned companion runtime (for example mascarade-n8n)." >&2
exit 1
fi
if ! docker_cmd_timeout docker volume create "${N8N_VOLUME}" >/dev/null 2>/dev/null; then
emit_runtime_error "docker_cli_timeout"
fi
if ! docker_cmd_timeout docker run -d \
--name "${N8N_CONTAINER}" \
-p "${N8N_PORT}:5678" \
-e GENERIC_TIMEZONE="${N8N_TIMEZONE}" \
-e TZ="${N8N_TIMEZONE}" \
-e N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true \
-v "${N8N_VOLUME}:/home/node/.n8n" \
"${N8N_IMAGE}" >/dev/null 2>/dev/null; then
emit_runtime_error "docker_cli_timeout"
fi
fi
deadline=$((SECONDS + WAIT_SECS))
while (( SECONDS < deadline )); do
if docker exec "${N8N_CONTAINER}" sh -lc 'which wget >/dev/null 2>&1 && wget -q -O /dev/null http://127.0.0.1:5678/'; then
if probe_http_ready; then
if [[ "${OUTPUT_JSON}" == "1" ]]; then
bash "${ROOT_DIR}/tools/ai/zeroclaw_integrations_status.sh" --json
else
@@ -68,5 +204,10 @@ while (( SECONDS < deadline )); do
sleep 1
done
if [[ "${OUTPUT_JSON}" == "1" ]]; then
bash "${ROOT_DIR}/tools/ai/zeroclaw_integrations_status.sh" --json
exit 1
fi
echo "n8n container did not become ready within ${WAIT_SECS}s: ${N8N_CONTAINER}" >&2
exit 1
+6 -6
View File
@@ -1163,8 +1163,8 @@ cat >"$INDEX_FILE" <<EOF
<div class="inline-row">
<select id="prRepoSelect" class="select">
<option value="electron-rare/Kill_LIFE">Kill_LIFE</option>
<option value="electron-rare/RTC_BL_PHONE">RTC_BL_PHONE</option>
<option value="electron-rare/le-mystere-professeur-zacus">Zacus</option>
<option value="electron-rare/RTC_HW">RTC_HW</option>
<option value="electron-rare/ZACUS_HW">Zacus</option>
</select>
<input id="prNumberInput" class="input" type="number" min="1" placeholder="PR #" />
</div>
@@ -1631,8 +1631,8 @@ cat >"$INDEX_FILE" <<EOF
const payload = {
repos: [
"electron-rare/Kill_LIFE",
"electron-rare/RTC_BL_PHONE",
"electron-rare/le-mystere-professeur-zacus"
"electron-rare/RTC_HW",
"electron-rare/ZACUS_HW"
],
strict: {
allow_draft: false,
@@ -2153,11 +2153,11 @@ cat >"$INDEX_FILE" <<EOF
}
const rtcPrompt =
general +
"\\nContexte cible: RTC_BL_PHONE sur ESP32 Audio Kit." +
"\\nContexte cible: RTC_HW sur ESP32 Audio Kit." +
"\\nAttendu: diagnostic audio/Bluetooth/WiFi/WebServer + erreurs recentes + prochaine action concise.";
const zacusPrompt =
general +
"\\nContexte cible: le-mystere-professeur-zacus sur Freenove ESP32-S3 (usbmodem)." +
"\\nContexte cible: ZACUS_HW sur Freenove ESP32-S3 (usbmodem)." +
"\\nAttendu: diagnostic UI/story/audio/network + erreurs recentes + prochaine action concise.";
document.getElementById("rtcPromptInput").value = rtcPrompt;
document.getElementById("zacusPromptInput").value = zacusPrompt;
+4 -1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import ast
import json
import os
import re
import subprocess
import sys
from pathlib import Path
@@ -61,7 +62,9 @@ def compact_repo_paths(value: str) -> str:
return text.replace(root_with_sep, "")
if text == str(ROOT):
return "."
return text
repo_name = re.escape(ROOT.name)
pattern = re.compile(rf"(^|[\s:(\"'`])(?:[A-Za-z]:)?[^ \t\n|\"'`)]*[\\/]{repo_name}[\\/]")
return pattern.sub(lambda match: match.group(1), text)
def compact_artifact_list_signal(value: str) -> str:
+354 -3
View File
@@ -10,10 +10,12 @@ import subprocess
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from dataclasses import field
ROOT = Path(__file__).resolve().parents[1]
PLAN_DOC = ROOT / "docs" / "plans" / "18_plan_enchainement_autonome_des_lots_utiles.md"
TODO_DOC = ROOT / "docs" / "plans" / "18_todo_enchainement_autonome_des_lots_utiles.md"
INTELLIGENCE_MEMORY_JSON = ROOT / "artifacts" / "cockpit" / "intelligence_program" / "latest.json"
NOISE_PATHS: tuple[str, ...] = (
"docs/plans/18_plan_enchainement_autonome_des_lots_utiles.md",
@@ -42,7 +44,14 @@ class Lot:
priority: int
paths: tuple[str, ...]
plan_refs: tuple[str, ...]
owner_repo: str
owner_agent: str
handoff_write_set: tuple[str, ...]
validations: tuple[Validation, ...]
owner_subagent: str | None = None
owner_team: str = "PM"
dependencies: tuple[str, ...] = field(default_factory=tuple)
rollback_plan: tuple[str, ...] = field(default_factory=tuple)
LOTS: tuple[Lot, ...] = (
@@ -83,6 +92,19 @@ LOTS: tuple[Lot, ...] = (
"specs/zeroclaw_dual_hw_todo.md",
"docs/plans/18_plan_enchainement_autonome_des_lots_utiles.md",
),
owner_repo="Kill_LIFE",
owner_agent="PM-Mesh",
owner_team="PM",
dependencies=(),
rollback_plan=(
"Revenir à la lane `zeroclaw` manuelle si le container n8n échoue de manière persistante.",
"Rejouer `bash tools/run_autonomous_next_lots.sh run --no-write` puis arbitrer manuellement.",
),
handoff_write_set=(
"specs/zeroclaw_dual_hw_todo.md",
"tools/cockpit/lot_chain.sh",
"tools/cockpit/run_next_lots_autonomously.sh",
),
validations=(
Validation(
name="zeroclaw_integrations_lot_verify",
@@ -94,6 +116,105 @@ LOTS: tuple[Lot, ...] = (
),
),
),
Lot(
key="mesh-governance",
title="Gouvernance mesh tri-repo",
description=(
"Versionner le contrat tri-repo, verrouiller les ownerships agents, "
"et outiller le preflight de synchro continue sans ecraser le travail "
"des autres contributeurs."
),
priority=7,
paths=(
"README.md",
"docs/index.md",
"docs/REFACTOR_MANIFEST_2026-03-20.md",
"docs/TRI_REPO_MESH_CONTRACT_2026-03-20.md",
"docs/AGENTIC_LANDSCAPE.md",
"docs/MACHINE_SYNC_STATUS_2026-03-20.md",
"docs/MCP_SETUP.md",
"docs/plans/12_plan_gestion_des_agents.md",
"docs/plans/19_todo_mesh_tri_repo.md",
"specs/03_plan.md",
"specs/04_tasks.md",
"specs/mesh_contracts.md",
"specs/contracts/agent_handoff.schema.json",
"specs/contracts/repo_snapshot.schema.json",
"specs/contracts/workflow_handshake.schema.json",
"specs/contracts/examples/agent_handoff.mesh.json",
"specs/contracts/examples/repo_snapshot.mesh.json",
"specs/contracts/examples/workflow_handshake.mesh.json",
"tools/cockpit/README.md",
"tools/cockpit/refonte_tui.sh",
"tools/cockpit/mesh_sync_preflight.sh",
"tools/cockpit/ssh_healthcheck.sh",
"tools/specs/mesh_contract_check.py",
".github/workflows/mesh_contracts.yml",
),
plan_refs=(
"docs/TRI_REPO_MESH_CONTRACT_2026-03-20.md",
"docs/plans/12_plan_gestion_des_agents.md",
"specs/03_plan.md",
),
owner_repo="Kill_LIFE",
owner_agent="PM-Mesh",
owner_team="PM",
dependencies=("zeroclaw-integrations",),
rollback_plan=(
"Retirer le lot `mesh-governance` du déclenchement auto si la convergence reste bloquée.",
"Conserver le contrat mesh en mode lecture seule et documenter la dérogation.",
),
handoff_write_set=(
"docs/TRI_REPO_MESH_CONTRACT_2026-03-20.md",
"docs/MACHINE_SYNC_STATUS_2026-03-20.md",
"docs/plans/19_todo_mesh_tri_repo.md",
"tools/cockpit/mesh_sync_preflight.sh",
"tools/cockpit/ssh_healthcheck.sh",
),
validations=(
Validation(
name="mesh_sync_preflight",
cmd=("bash", "tools/cockpit/mesh_sync_preflight.sh", "--json"),
),
Validation(
name="ssh_healthcheck",
cmd=("bash", "tools/cockpit/ssh_healthcheck.sh", "--json"),
),
Validation(
name="mesh_agent_handoff_contract",
cmd=(
"python3",
"tools/specs/mesh_contract_check.py",
"--schema",
"specs/contracts/agent_handoff.schema.json",
"--instance",
"specs/contracts/examples/agent_handoff.mesh.json",
),
),
Validation(
name="mesh_repo_snapshot_contract",
cmd=(
"python3",
"tools/specs/mesh_contract_check.py",
"--schema",
"specs/contracts/repo_snapshot.schema.json",
"--instance",
"specs/contracts/examples/repo_snapshot.mesh.json",
),
),
Validation(
name="mesh_workflow_handshake_contract",
cmd=(
"python3",
"tools/specs/mesh_contract_check.py",
"--schema",
"specs/contracts/workflow_handshake.schema.json",
"--instance",
"specs/contracts/examples/workflow_handshake.mesh.json",
),
),
),
),
Lot(
key="mcp-runtime",
title="Alignement MCP runtime local",
@@ -117,6 +238,20 @@ LOTS: tuple[Lot, ...] = (
"docs/plans/15_plan_mcp_runtime_alignment.md",
"docs/plans/17_plan_target_architecture_mcp_agentics_2028.md",
),
owner_repo="Kill_LIFE",
owner_agent="Runtime-Companion",
owner_team="Architect-Firmware",
dependencies=("zeroclaw-integrations",),
rollback_plan=(
"Désactiver les changements MCP introduits dans le lot et revenir à la configuration de référence.",
"Conserver la preuve de doc dans `docs/MCP_SETUP.md` et relancer uniquement en mode validation.",
),
handoff_write_set=(
"docs/MCP_SETUP.md",
"tools/bootstrap_mac_mcp.sh",
"tools/lib/runtime_home.sh",
"tools/run_github_dispatch_mcp.sh",
),
validations=(
Validation(
name="bootstrap_codex_dry_run",
@@ -161,6 +296,19 @@ LOTS: tuple[Lot, ...] = (
"docs/plans/16_plan_cad_modeling_stack.md",
"docs/plans/17_plan_target_architecture_mcp_agentics_2028.md",
),
owner_repo="Kill_LIFE",
owner_agent="Embedded-CAD",
owner_team="Architect-Firmware",
dependencies=("mcp-runtime",),
rollback_plan=(
"Retour automatique au mode container-only (`tools/hw/cad_stack.sh`).",
"Conserver les chemins explicites de script pour éviter une dérive host-first.",
),
handoff_write_set=(
"docs/MCP_SETUP.md",
"tools/hw/cad_stack.sh",
"tools/hw/run_kicad_mcp.sh",
),
validations=(
Validation(
name="kicad_doctor",
@@ -180,6 +328,72 @@ LOTS: tuple[Lot, ...] = (
),
),
),
Lot(
key="yiacad-fusion",
title="Fusion KiCad + FreeCAD IA-native",
description=(
"Mettre en place le lot YiACAD (`prepare`, `smoke`, `status`, "
"`logs`, `clean-logs`) et la synthese operateur associee sans "
"fusion automatique de `main`."
),
priority=25,
paths=(
"README.md",
"SYNTHESE_AGENTIQUE.md",
"docs/AGENTIC_LANDSCAPE.md",
"docs/AI_WORKFLOWS.md",
"docs/CAD_AI_NATIVE_FORK_STRATEGY.md",
"docs/OSS_AI_NATIVE_CAD_RESEARCH_2026-03-20.md",
"docs/WEB_RESEARCH_OPEN_SOURCE_2026-03-20.md",
"docs/plans/12_plan_gestion_des_agents.md",
"docs/plans/18_plan_enchainement_autonome_des_lots_utiles.md",
"docs/plans/18_todo_enchainement_autonome_des_lots_utiles.md",
"docs/plans/19_todo_mesh_tri_repo.md",
"specs/03_plan.md",
"specs/04_tasks.md",
"tools/cad/ai_native_forks.sh",
"tools/cad/yiacad_fusion_lot.sh",
"tools/cockpit/refonte_tui.sh",
"tools/cockpit/render_weekly_refonte_summary.sh",
),
plan_refs=(
"docs/CAD_AI_NATIVE_FORK_STRATEGY.md",
"docs/OSS_AI_NATIVE_CAD_RESEARCH_2026-03-20.md",
"docs/plans/18_todo_enchainement_autonome_des_lots_utiles.md",
),
owner_repo="Kill_LIFE",
owner_agent="CAD-Fusion",
owner_team="CAD-Fusion",
dependencies=("cad-mcp-host",),
rollback_plan=(
"Geler la branche `kill-life-ai-native` et revenir au mode container-only (`tools/hw/cad_stack.sh`).",
"Conserver les artefacts YiACAD (`artifacts/cad-fusion`) et la synthese hebdomadaire comme preuve.",
"Basculer l'execution en mode `status` + `clean-logs` si le smoke bloque.",
),
handoff_write_set=(
"docs/CAD_AI_NATIVE_FORK_STRATEGY.md",
"docs/OSS_AI_NATIVE_CAD_RESEARCH_2026-03-20.md",
"tools/cad/ai_native_forks.sh",
"tools/cad/yiacad_fusion_lot.sh",
"tools/cockpit/refonte_tui.sh",
"tools/cockpit/render_weekly_refonte_summary.sh",
),
validations=(
Validation(
name="yiacad_prepare",
cmd=("bash", "tools/cad/yiacad_fusion_lot.sh", "--action", "prepare"),
),
Validation(
name="yiacad_status",
cmd=("bash", "tools/cad/yiacad_fusion_lot.sh", "--action", "status"),
),
Validation(
name="yiacad_smoke",
cmd=("bash", "tools/cad/yiacad_fusion_lot.sh", "--action", "smoke"),
required=False,
),
),
),
Lot(
key="python-local",
title="Execution Python repo-locale",
@@ -194,6 +408,19 @@ LOTS: tuple[Lot, ...] = (
"tools/test_python.sh",
),
plan_refs=("docs/plans/15_plan_mcp_runtime_alignment.md",),
owner_repo="Kill_LIFE",
owner_agent="Firmware",
owner_team="Architect-Firmware",
dependencies=("mcp-runtime", "cad-mcp-host"),
rollback_plan=(
"Revenir à l'exécution Python système si la venv locale est instable.",
"Limiter les changements au lot `tools/test_python.sh` jusqu'à stabilisation.",
),
handoff_write_set=(
"tools/test_python.sh",
"tools/run_validate_specs_mcp.sh",
"tools/validate_specs_mcp_smoke.py",
),
validations=(
Validation(
name="python_stable_suite",
@@ -225,6 +452,29 @@ def now_label() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def summarize_lot_status(results: list[dict[str, object]]) -> str:
if not results:
return "pending"
if any(item["status"] == "blocked" for item in results):
return "blocked"
if any(item["status"] == "advisory" for item in results):
return "degraded"
return "done"
def lot_handoff_info(lot: Lot, results: list[dict[str, object]]) -> dict[str, object]:
return {
"lot_id": lot.key,
"owner_repo": lot.owner_repo,
"owner_agent": lot.owner_agent,
"owner_subagent": lot.owner_subagent,
"owner_team": lot.owner_team,
"write_set": list(lot.handoff_write_set),
"status": summarize_lot_status(results),
"evidence": list(lot.plan_refs),
}
def run_cmd(cmd: tuple[str, ...]) -> subprocess.CompletedProcess[str]:
return subprocess.run(
list(cmd),
@@ -309,6 +559,10 @@ def output_tail(text: str) -> str:
def classify_blocker(output: str) -> str | None:
lower = output.lower()
if "docker_timeout" in lower or "docker cli timeout" in lower or "timed out while checking" in lower:
return "Docker CLI locale bloquee ou en timeout sur cette machine."
if "cli_timeout" in lower:
return "CLI locale en timeout pendant la validation."
if any(token in lower for token in ("auth", "token", "permission", "credential", "secret")):
return "Secrets/identifiants manquants pour une validation live."
if "docker" in lower:
@@ -345,6 +599,21 @@ def execute_lot(lot: Lot) -> list[dict[str, object]]:
return results
def load_intelligence_snapshot() -> dict[str, object] | None:
if INTELLIGENCE_MEMORY_JSON.exists():
try:
return json.loads(INTELLIGENCE_MEMORY_JSON.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return None
proc = run_cmd(("bash", "tools/cockpit/intelligence_tui.sh", "--action", "memory", "--json"))
if proc.returncode != 0:
return None
try:
return json.loads(proc.stdout)
except json.JSONDecodeError:
return None
def render_plan(
branch: str,
dirty_paths: list[str],
@@ -352,6 +621,7 @@ def render_plan(
behind: int,
lots: list[Lot],
results: dict[str, list[dict[str, object]]],
intelligence: dict[str, object] | None,
) -> str:
lines = [
"# 18) Plan d'enchainement autonome des lots utiles",
@@ -360,6 +630,15 @@ def render_plan(
"",
"Ce plan est regenere localement par `tools/autonomous_next_lots.py`.",
"",
"## Cycle lot opérationnel",
"",
"1. detection: `bash tools/run_autonomous_next_lots.sh status`",
"2. run: `bash tools/run_autonomous_next_lots.sh run`",
"3. proof JSON: `bash tools/run_autonomous_next_lots.sh json`",
"4. mise a jour plan/todo",
"5. revue owner + routing lot suivant",
"6. synthese operateur + lot exit checklist",
"",
"## Objectif",
"",
"Detecter les deltas utiles a traiter, prioriser le prochain lot executable,",
@@ -387,6 +666,26 @@ def render_plan(
lines.append(f"- `{path}`")
lines.append("")
lines.append("## Gouvernance intelligence")
lines.append("")
if intelligence:
lines.append(f"- status: `{intelligence.get('status', 'unknown')}`")
lines.append(f"- open_task_count: `{intelligence.get('open_task_count', 0)}`")
memory_artifacts = intelligence.get("memory_artifacts") or {}
json_path = memory_artifacts.get("json") or str(INTELLIGENCE_MEMORY_JSON.relative_to(ROOT))
lines.append(f"- memory: `{json_path}`")
next_steps = intelligence.get("next_steps") or []
if next_steps:
lines.append("- next actions:")
for step in next_steps[:3]:
lines.append(f" - {step}")
else:
lines.append("- intelligence snapshot: unavailable")
lines.append("")
lines.append("## Matrice lot -> owner -> dépendances -> rollback")
lines.append("")
lines.append("## Lots detectes")
lines.append("")
if not lots:
@@ -401,6 +700,15 @@ def render_plan(
for index, lot in enumerate(lots, start=1):
lines.append(f"### {index}. `{lot.key}` — {lot.title}")
lines.append("")
lines.append(f"- owner: team={lot.owner_team}, agent={lot.owner_agent}, repo={lot.owner_repo}")
if lot.dependencies:
lines.append("- dependencies: " + ", ".join(f"`{dep}`" for dep in lot.dependencies))
else:
lines.append("- dependencies: `none`")
if lot.rollback_plan:
lines.append("- rollback:")
for entry in lot.rollback_plan:
lines.append(f" - {entry}")
lines.append(lot.description)
lines.append("")
refs = ", ".join(f"`{ref}`" for ref in lot.plan_refs)
@@ -415,6 +723,10 @@ def render_plan(
)
else:
lines.append("- validations: non executees sur ce cycle")
lines.append(
f"- handoff: lot_id={lot.key}, owner_repo={lot.owner_repo}, owner_agent={lot.owner_agent}, owner_subagent={lot.owner_subagent or 'none'}, status={summarize_lot_status(lot_results)}"
)
lines.append(f"- write_set: {', '.join(lot.handoff_write_set)}")
lines.append("")
blockers = sorted(
@@ -439,11 +751,16 @@ def render_plan(
lines.append("- `bash tools/run_autonomous_next_lots.sh status`")
lines.append("- `bash tools/run_autonomous_next_lots.sh run`")
lines.append("- `bash tools/run_autonomous_next_lots.sh json`")
lines.append("- `bash tools/cockpit/render_weekly_refonte_summary.sh`")
lines.append("")
return "\n".join(lines)
def render_todo(lots: list[Lot], results: dict[str, list[dict[str, object]]]) -> str:
def render_todo(
lots: list[Lot],
results: dict[str, list[dict[str, object]]],
intelligence: dict[str, object] | None,
) -> str:
lines = [
"# 18) TODO enchainement autonome des lots utiles",
"",
@@ -462,6 +779,18 @@ def render_todo(lots: list[Lot], results: dict[str, list[dict[str, object]]]) ->
)
return "\n".join(lines)
if intelligence:
lines.append("## `intelligence-governance`")
lines.append("")
lines.append(f"- status: {intelligence.get('status', 'unknown')}")
lines.append(f"- open_task_count: {intelligence.get('open_task_count', 0)}")
for step in (intelligence.get("next_steps") or [])[:3]:
lines.append(f"- next: {step}")
memory_artifacts = intelligence.get("memory_artifacts") or {}
if memory_artifacts.get("json"):
lines.append(f"- evidence: {memory_artifacts['json']}")
lines.append("")
for lot in lots:
lines.append(f"## `{lot.key}` — {lot.title}")
lines.append("")
@@ -476,6 +805,17 @@ def render_todo(lots: list[Lot], results: dict[str, list[dict[str, object]]]) ->
summary = str(item["summary"] or "aucun detail")
lines.append(f"- {status}: `{cmd}`")
lines.append(f" resume: {summary}")
lines.append(f"- handoff lot_id: {lot.key}")
lines.append(f" owner_repo={lot.owner_repo}")
lines.append(f" owner_agent={lot.owner_agent}")
lines.append(f" owner_team={lot.owner_team}")
if lot.dependencies:
lines.append(" dependencies=" + ", ".join(f"{dep}" for dep in lot.dependencies))
else:
lines.append(" dependencies=none")
if lot.rollback_plan:
lines.append(" rollback=" + " | ".join(lot.rollback_plan))
lines.append(f" write_set={', '.join(lot.handoff_write_set)}")
lines.append("")
return "\n".join(lines)
@@ -497,8 +837,10 @@ def main() -> int:
for lot in lots:
results[lot.key] = execute_lot(lot)
plan_text = render_plan(branch, dirty_paths, ahead, behind, lots, results)
todo_text = render_todo(lots, results)
intelligence = load_intelligence_snapshot()
plan_text = render_plan(branch, dirty_paths, ahead, behind, lots, results, intelligence)
todo_text = render_todo(lots, results, intelligence)
should_write = not args.no_write and args.mode != "json"
if should_write:
write_docs(plan_text, todo_text)
@@ -514,12 +856,21 @@ def main() -> int:
"title": lot.title,
"priority": lot.priority,
"plan_refs": list(lot.plan_refs),
"handoff": lot_handoff_info(lot, results.get(lot.key, [])),
"dependencies": list(lot.dependencies),
"rollback_plan": list(lot.rollback_plan),
"results": results.get(lot.key, []),
}
for lot in lots
],
"plan_doc": str(PLAN_DOC.relative_to(ROOT)),
"todo_doc": str(TODO_DOC.relative_to(ROOT)),
"intelligence": {
"status": intelligence.get("status", "unknown") if intelligence else "unknown",
"open_task_count": intelligence.get("open_task_count", 0) if intelligence else 0,
"next_steps": (intelligence.get("next_steps", []) if intelligence else []),
"memory_artifacts": intelligence.get("memory_artifacts", {}) if intelligence else {},
},
}
if args.mode == "json":
+2 -3
View File
@@ -209,9 +209,8 @@ if [[ -z "${MASCARADE_DIR:-}" ]]; then
MASCARADE_DIR="$(
kill_life_resolve_mascarade_dir \
"$REPO_DIR" \
"core/mascarade/integrations/knowledge_base.py" \
"core/mascarade/integrations/github_dispatch.py" \
"finetune/kicad_mcp_server"
"core/mascarade" \
"finetune"
)"
fi
[[ -n "$MASCARADE_DIR" ]] || { echo "Unable to resolve mascarade companion path" >&2; exit 1; }
+335
View File
@@ -0,0 +1,335 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
BASE_DIR="${KILL_LIFE_CAD_AI_BASE_DIR:-$ROOT_DIR/.runtime-home/cad-ai-native-forks}"
KICAD_UPSTREAM="KiCad/KiCad"
FREECAD_UPSTREAM="FreeCAD/FreeCAD"
TARGET_OWNER="${KILL_LIFE_FORK_OWNER:-electron-rare}"
TARGET_BRANCH="${KILL_LIFE_FORK_BRANCH:-kill-life-ai-native}"
CREATE_REMOTE_FORKS=0
DRY_RUN=0
FORCE=0
PROJECTS="kicad freecad"
usage() {
cat <<'EOF'
Usage: tools/cad/ai_native_forks.sh [options]
Prépare des forks locaux KiCad/FreeCAD pour le lane IA-native Kill_LIFE.
Options:
--owner OWNER Propriétaire GitHub du fork (défaut: electron-rare)
--branch BRANCH Branche locale cible (défaut: kill-life-ai-native)
--base-dir DIR Dossier base local (défaut: .runtime-home/cad-ai-native-forks)
--projects LIST Projets à traiter, ex: "kicad freecad" (défaut: kicad freecad)
--create-forks Crée les forks GitHub via `gh repo fork` si absents
--dry-run Affiche les opérations sans écrire
--force Écrase les répertoires existants
-h, --help Aide
Env:
KILL_LIFE_CAD_AI_BASE_DIR Override du dossier base local
KILL_LIFE_FORK_OWNER Override du propriétaire du fork
KILL_LIFE_FORK_BRANCH Override de la branche IA-native
EOF
}
log() {
printf '[kill_life:cad-forks] %s\n' "$*" >&2
}
die() {
printf '[kill_life:cad-forks][err] %s\n' "$*" >&2
exit 1
}
ensure_dir() {
local dir="$1"
if [ -d "$dir" ] && [ "$FORCE" -eq 1 ]; then
rm -rf "$dir"
fi
mkdir -p "$dir"
}
run_or_dry() {
local cmd="$*"
if [ "$DRY_RUN" -eq 1 ]; then
log "dry-run: $cmd"
return 0
fi
eval "$cmd"
}
gh_auth_check() {
command -v gh >/dev/null 2>&1 || die "gh CLI requis pour la création de forks"
if ! gh auth status >/dev/null 2>&1; then
die "gh CLI non authentifié. Lance `gh auth login` puis relance."
fi
}
create_fork_target() {
local upstream_slug="$1"
local target_repo="$2"
if gh repo view "$target_repo" >/dev/null 2>&1; then
log "Fork existant: $target_repo"
return 0
fi
log "Fork manquant: $target_repo"
if ! gh repo fork "$upstream_slug" --org "$TARGET_OWNER" 2>/tmp/.kill_life_gh_fork_err; then
if grep -qi "Fork\\.organization is invalid" /tmp/.kill_life_gh_fork_err; then
log "Target owner is user account; retry without --org"
if ! gh repo fork "$upstream_slug" 2>/tmp/.kill_life_gh_fork_err; then
cat /tmp/.kill_life_gh_fork_err >&2
return 1
fi
return 0
fi
cat /tmp/.kill_life_gh_fork_err >&2
return 1
fi
}
resolve_fork_repo() {
local upstream_slug="$1"
local target_owner="$2"
local target_repo="$3"
if gh repo view "$target_repo" >/dev/null 2>&1; then
printf '%s' "$target_repo"
return 0
fi
gh api \
"repos/$upstream_slug/forks" \
--paginate \
--jq ".[] | select(.owner.login == \"$target_owner\") | .full_name" \
| head -n 1
}
ensure_remote() {
local dir="$1"
local name="$2"
local url="$3"
if git -C "$dir" remote get-url "$name" >/dev/null 2>&1; then
run_or_dry "git -C $dir remote set-url $name $url"
return
fi
run_or_dry "git -C $dir remote add $name $url"
}
ensure_remote_if_missing() {
local dir="$1"
local name="$2"
local url="$3"
if git -C "$dir" remote get-url "$name" >/dev/null 2>&1; then
return
fi
run_or_dry "git -C $dir remote add $name $url"
}
sync_remote_push_url() {
local dir="$1"
local name="$2"
local url="$3"
run_or_dry "git -C $dir remote set-url --push $name $url"
}
sync_remote_push_url_if_missing() {
local dir="$1"
local name="$2"
local url="$3"
if git -C "$dir" remote get-url "$name" >/dev/null 2>&1; then
return
fi
run_or_dry "git -C $dir remote set-url --push $name $url"
}
derive_repo_name() {
local slug="$1"
printf '%s' "${slug##*/}"
}
derive_repo_remote_url() {
local owner="$1"
local slug="$2"
printf '%s' "https://github.com/$owner/$slug.git"
}
ensure_project_repo() {
local project="$1"
local upstream_slug="$2"
local upstream_url="$3"
local repo_name
local target_repo
repo_name="$(derive_repo_name "$upstream_slug")"
target_repo="$TARGET_OWNER/$repo_name"
local target_url
target_url="$(derive_repo_remote_url "$TARGET_OWNER" "$repo_name")"
local project_dir="$BASE_DIR/$project-ki"
local default_branch
local remote_head
local source_url
local resolved_target_repo
local use_fork=1
log "=== $project ($upstream_slug) ==="
ensure_dir "$BASE_DIR"
if [ "$CREATE_REMOTE_FORKS" -eq 1 ]; then
gh_auth_check
create_fork_target "$upstream_slug" "$target_repo" || die "Échec de création/référence du fork $target_repo"
resolved_target_repo="$(resolve_fork_repo "$upstream_slug" "$TARGET_OWNER" "$target_repo")"
if [ -z "$resolved_target_repo" ]; then
die "Impossible de résoudre le nom du fork pour $target_repo"
fi
target_repo="$resolved_target_repo"
target_url="$(derive_repo_remote_url "${resolved_target_repo%/*}" "${resolved_target_repo#*/}")"
source_url="$target_url"
else
source_url="$upstream_url"
use_fork=0
fi
if [ -d "$project_dir/.git" ]; then
log "Repo existante détectée: $project_dir"
else
if [ "$DRY_RUN" -eq 1 ]; then
log "Création clone prévue: $project_dir <- $source_url"
else
if [ "$use_fork" -eq 1 ]; then
if [ -d "$project_dir" ]; then
rm -rf "$project_dir"
fi
fi
log "Clone: $source_url"
run_or_dry "git clone --filter=blob:none --depth=1 $source_url $project_dir"
fi
fi
if [ "$DRY_RUN" -eq 1 ] && [ ! -d "$project_dir/.git" ]; then
log "skip remotes (dry-run + no local repo yet)"
return
fi
local canonical_origin
if [ "$use_fork" -eq 1 ]; then
ensure_remote "$project_dir" origin "$target_url"
sync_remote_push_url "$project_dir" origin "$target_url"
ensure_remote "$project_dir" upstream "$upstream_url"
sync_remote_push_url "$project_dir" upstream "$upstream_url"
else
ensure_remote_if_missing "$project_dir" origin "$upstream_url"
sync_remote_push_url_if_missing "$project_dir" origin "$upstream_url"
ensure_remote "$project_dir" upstream "$upstream_url"
sync_remote_push_url "$project_dir" upstream "$upstream_url"
fi
remote_head="$(git -C $project_dir remote show origin 2>/dev/null | awk '/HEAD branch:/{print $NF}' | tr -d '[:space:]')"
default_branch="${remote_head:-main}"
if [ -z "$default_branch" ] || [ "$default_branch" = "(unknown)" ]; then
default_branch="main"
fi
log "default branch=$default_branch"
run_or_dry "git -C $project_dir fetch --all --prune"
if git -C "$project_dir" show-ref --verify --quiet "refs/heads/$TARGET_BRANCH"; then
run_or_dry "git -C $project_dir switch $TARGET_BRANCH"
else
run_or_dry "git -C $project_dir switch -c $TARGET_BRANCH origin/$default_branch"
fi
log "Statut $project: $(git -C $project_dir rev-parse --abbrev-ref HEAD) sur $(git -C $project_dir rev-parse --short HEAD)"
}
while [ "$#" -gt 0 ]; do
case "$1" in
--owner)
[ "$#" -ge 2 ] || die "--owner requires a value"
TARGET_OWNER="$2"
shift 2
;;
--branch)
[ "$#" -ge 2 ] || die "--branch requires a value"
TARGET_BRANCH="$2"
shift 2
;;
--base-dir)
[ "$#" -ge 2 ] || die "--base-dir requires a value"
BASE_DIR="$2"
shift 2
;;
--projects)
[ "$#" -ge 2 ] || die "--projects requires a value"
PROJECTS="$2"
shift 2
;;
--create-forks)
CREATE_REMOTE_FORKS=1
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
--force)
FORCE=1
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
break
;;
*)
die "Option inconnue: $1"
;;
esac
done
mkdir -p "$BASE_DIR"
manifest_path="$BASE_DIR/manifest.md"
cat >"$manifest_path" <<EOF
# CAD AI-native fork manifest
BASE_DIR=$BASE_DIR
OWNER=$TARGET_OWNER
BRANCH=$TARGET_BRANCH
DRY_RUN=$DRY_RUN
CREATE_REMOTE_FORKS=$CREATE_REMOTE_FORKS
EOF
for project in $PROJECTS; do
case "$project" in
kicad)
ensure_project_repo "$project" "$KICAD_UPSTREAM" "$(derive_repo_remote_url "KiCad" "KiCad")"
;;
freecad)
ensure_project_repo "$project" "$FREECAD_UPSTREAM" "$(derive_repo_remote_url "FreeCAD" "FreeCAD")"
;;
*)
die "Projet inconnu: $project"
;;
esac
done
{
echo "## Remotes"
for project in $PROJECTS; do
if [ "$DRY_RUN" -eq 0 ] && [ -d "$BASE_DIR/$project-ki/.git" ]; then
echo "### $project"
git -C "$BASE_DIR/$project-ki" remote -v
echo
fi
done
} >>"$manifest_path"
log "Manifest écrit dans $manifest_path"
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
KICAD_SRC="${ROOT_DIR}/tools/cad/integrations/kicad/yiacad_kicad_plugin"
FREECAD_SRC="${ROOT_DIR}/tools/cad/integrations/freecad/YiACADWorkbench"
KICAD_PLUGIN_DIR="${KICAD_PLUGIN_DIR:-${HOME}/Library/Application Support/kicad/scripting/plugins}"
FREECAD_MOD_DIR="${FREECAD_MOD_DIR:-${HOME}/Library/Application Support/FreeCAD/Mod}"
COMMAND="status"
usage() {
cat <<'EOF'
Usage: bash tools/cad/install_yiacad_native_gui.sh <status|install|uninstall>
Install or inspect the YiACAD native GUI surfaces for KiCad and FreeCAD.
Env overrides:
KICAD_PLUGIN_DIR
FREECAD_MOD_DIR
EOF
}
link_target() {
local link_path="$1"
if [[ -L "$link_path" ]]; then
readlink "$link_path"
elif [[ -e "$link_path" ]]; then
printf '%s' "<non-symlink>"
else
printf '%s' "<missing>"
fi
}
status() {
printf 'ROOT_DIR=%s\n' "$ROOT_DIR"
printf 'KICAD_PLUGIN_DIR=%s\n' "$KICAD_PLUGIN_DIR"
printf 'FREECAD_MOD_DIR=%s\n' "$FREECAD_MOD_DIR"
printf 'KICAD_LINK=%s\n' "$(link_target "${KICAD_PLUGIN_DIR}/yiacad_kicad_plugin")"
printf 'FREECAD_LINK=%s\n' "$(link_target "${FREECAD_MOD_DIR}/YiACADWorkbench")"
}
install_links() {
mkdir -p "$KICAD_PLUGIN_DIR" "$FREECAD_MOD_DIR"
ln -sfn "$KICAD_SRC" "${KICAD_PLUGIN_DIR}/yiacad_kicad_plugin"
ln -sfn "$FREECAD_SRC" "${FREECAD_MOD_DIR}/YiACADWorkbench"
status
}
uninstall_links() {
rm -f "${KICAD_PLUGIN_DIR}/yiacad_kicad_plugin"
rm -f "${FREECAD_MOD_DIR}/YiACADWorkbench"
status
}
if [[ $# -gt 0 ]]; then
COMMAND="$1"
fi
case "$COMMAND" in
status)
status
;;
install)
install_links
;;
uninstall)
uninstall_links
;;
-h|--help)
usage
;;
*)
echo "Unknown command: $COMMAND" >&2
usage >&2
exit 2
;;
esac
@@ -0,0 +1 @@
# YiACAD FreeCAD workbench bootstrap placeholder.
@@ -0,0 +1,71 @@
from __future__ import annotations
import os
import FreeCADGui # type: ignore
from . import yiacad_freecad_gui
class _OpenPanelCommand:
def GetResources(self):
return {
"MenuText": "YiACAD AI Panel",
"ToolTip": "Open the YiACAD AI dialog for FreeCAD",
"Pixmap": os.path.join(os.path.dirname(__file__), "Resources", "icons", "yiacad_ai.svg"),
}
def Activated(self):
yiacad_freecad_gui.show_dialog()
def IsActive(self):
return True
class _StatusCommand:
def GetResources(self):
return {
"MenuText": "YiACAD Status",
"ToolTip": "Show the latest YiACAD status snapshot",
"Pixmap": os.path.join(os.path.dirname(__file__), "Resources", "icons", "yiacad_ai.svg"),
}
def Activated(self):
yiacad_freecad_gui.show_status_message()
def IsActive(self):
return True
class _ArtifactsCommand:
def GetResources(self):
return {
"MenuText": "YiACAD Artifacts",
"ToolTip": "Open the local Kill_LIFE artifacts folder",
"Pixmap": os.path.join(os.path.dirname(__file__), "Resources", "icons", "yiacad_ai.svg"),
}
def Activated(self):
yiacad_freecad_gui.open_artifacts()
def IsActive(self):
return True
class YiACADWorkbench(FreeCADGui.Workbench): # type: ignore[misc]
MenuText = "YiACAD AI"
ToolTip = "AI-native CAD utilities for FreeCAD linked to Kill_LIFE"
Icon = os.path.join(os.path.dirname(__file__), "Resources", "icons", "yiacad_ai.svg")
def Initialize(self):
FreeCADGui.addCommand("YiACAD_OpenPanel", _OpenPanelCommand())
FreeCADGui.addCommand("YiACAD_Status", _StatusCommand())
FreeCADGui.addCommand("YiACAD_Artifacts", _ArtifactsCommand())
self.appendToolbar("YiACAD AI", ["YiACAD_OpenPanel", "YiACAD_Status", "YiACAD_Artifacts"])
self.appendMenu("YiACAD AI", ["YiACAD_OpenPanel", "YiACAD_Status", "YiACAD_Artifacts"])
def GetClassName(self):
return "Gui::PythonWorkbench"
FreeCADGui.addWorkbench(YiACADWorkbench())
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect x="6" y="8" width="52" height="48" rx="8" fill="#10283a"/>
<rect x="14" y="16" width="16" height="16" rx="3" fill="#f6c445"/>
<rect x="34" y="16" width="16" height="16" rx="3" fill="#5bc0eb"/>
<rect x="14" y="36" width="36" height="10" rx="3" fill="#f2f4f8"/>
<circle cx="52" cy="42" r="6" fill="#ee6c4d"/>
</svg>

After

Width:  |  Height:  |  Size: 391 B

@@ -0,0 +1,154 @@
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
import FreeCAD # type: ignore
import FreeCADGui # type: ignore
try:
from PySide2 import QtWidgets # type: ignore
except Exception: # pragma: no cover
from PySide import QtGui as QtWidgets # type: ignore
def _candidate_roots() -> list[Path]:
candidates: list[Path] = []
if os.environ.get("KILL_LIFE_ROOT"):
candidates.append(Path(os.environ["KILL_LIFE_ROOT"]).expanduser())
here = Path(__file__).resolve()
candidates.extend(here.parents)
return candidates
def repo_root() -> Path:
for candidate in _candidate_roots():
bridge = candidate / "tools" / "cad" / "yiacad_ai_bridge.py"
if bridge.exists():
return candidate
raise RuntimeError("Unable to locate Kill_LIFE root for YiACAD bridge")
def bridge_script() -> Path:
return repo_root() / "tools" / "cad" / "yiacad_ai_bridge.py"
def run_bridge(args: list[str]) -> dict:
proc = subprocess.run(
["python3", str(bridge_script()), *args],
cwd=repo_root(),
text=True,
capture_output=True,
check=False,
timeout=30,
)
if proc.returncode != 0:
raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "YiACAD bridge failed")
return json.loads(proc.stdout.strip() or "{}")
def current_document_path() -> str:
doc = FreeCAD.ActiveDocument
if doc is None:
return ""
return str(getattr(doc, "FileName", "") or "")
def selection_summary() -> list[str]:
selected = []
for item in FreeCADGui.Selection.getSelectionEx():
if getattr(item, "ObjectName", None):
selected.append(str(item.ObjectName))
return selected
def _status_summary() -> str:
payload = run_bridge(["status"])
lines = payload.get("yiacad_status_excerpt") or []
latest_request = payload.get("latest_request") or "(none)"
excerpt = "\n".join(lines[:8]) if lines else "No YiACAD status snapshot yet."
return f"Latest request:\n{latest_request}\n\nStatus:\n{excerpt}"
def show_status_message() -> None:
QtWidgets.QMessageBox.information(None, "YiACAD Status", _status_summary())
def open_artifacts() -> None:
subprocess.Popen(["open", str(repo_root() / "artifacts")])
class YiACADDialog(QtWidgets.QDialog):
def __init__(self) -> None:
super().__init__(None)
self.setWindowTitle("YiACAD AI for FreeCAD")
self.resize(560, 420)
layout = QtWidgets.QVBoxLayout(self)
self.intent = QtWidgets.QComboBox(self)
self.intent.addItems(
[
"model-assist",
"parametric-refactor",
"step-export-review",
"ecad-mcad-sync",
]
)
self.source = QtWidgets.QLineEdit(self)
self.source.setReadOnly(True)
self.source.setText(current_document_path())
self.prompt = QtWidgets.QPlainTextEdit(self)
self.prompt.setPlainText("Describe the FreeCAD task to queue for YiACAD.")
layout.addWidget(QtWidgets.QLabel("Intent", self))
layout.addWidget(self.intent)
layout.addWidget(QtWidgets.QLabel("Document / source path", self))
layout.addWidget(self.source)
layout.addWidget(QtWidgets.QLabel("Prompt", self))
layout.addWidget(self.prompt)
button_row = QtWidgets.QHBoxLayout()
queue_button = QtWidgets.QPushButton("Queue AI Request", self)
status_button = QtWidgets.QPushButton("YiACAD Status", self)
artifacts_button = QtWidgets.QPushButton("Open Artifacts", self)
close_button = QtWidgets.QPushButton("Close", self)
queue_button.clicked.connect(self.on_queue)
status_button.clicked.connect(lambda: show_status_message())
artifacts_button.clicked.connect(lambda: open_artifacts())
close_button.clicked.connect(self.close)
for button in (queue_button, status_button, artifacts_button, close_button):
button_row.addWidget(button)
layout.addLayout(button_row)
def on_queue(self) -> None:
payload = run_bridge(
[
"request",
"--surface",
"freecad",
"--intent",
self.intent.currentText(),
"--prompt",
self.prompt.toPlainText().strip(),
"--source-path",
self.source.text().strip(),
"--selection-json",
json.dumps(selection_summary(), ensure_ascii=True),
]
)
QtWidgets.QMessageBox.information(
self,
"YiACAD",
f"Queued request:\n{payload.get('request_path', '')}",
)
def show_dialog() -> None:
dialog = YiACADDialog()
dialog.exec_()
@@ -0,0 +1,3 @@
from .yiacad_action import YiACADActionPlugin
YiACADActionPlugin().register()
@@ -0,0 +1,4 @@
from ._common import fetch_status_payload
if __name__ == "__main__":
print(fetch_status_payload())
@@ -0,0 +1,67 @@
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
def _candidate_roots() -> list[Path]:
candidates: list[Path] = []
if os.environ.get("KILL_LIFE_ROOT"):
candidates.append(Path(os.environ["KILL_LIFE_ROOT"]).expanduser())
here = Path(__file__).resolve()
candidates.extend(here.parents)
return candidates
def repo_root() -> Path:
for candidate in _candidate_roots():
bridge = candidate / "tools" / "cad" / "yiacad_ai_bridge.py"
if bridge.exists():
return candidate
raise RuntimeError("Unable to locate Kill_LIFE root for YiACAD bridge")
def bridge_script() -> Path:
return repo_root() / "tools" / "cad" / "yiacad_ai_bridge.py"
def run_bridge(args: list[str]) -> dict:
proc = subprocess.run(
["python3", str(bridge_script()), *args],
cwd=repo_root(),
text=True,
capture_output=True,
check=False,
timeout=30,
)
if proc.returncode != 0:
raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "YiACAD bridge failed")
return json.loads(proc.stdout.strip() or "{}")
def queue_request(surface: str, intent: str, prompt: str, source_path: str, selection: list[str]) -> dict:
return run_bridge(
[
"request",
"--surface",
surface,
"--intent",
intent,
"--prompt",
prompt,
"--source-path",
source_path,
"--selection-json",
json.dumps(selection, ensure_ascii=True),
]
)
def fetch_status_payload() -> dict:
return run_bridge(["status"])
def open_path(path: Path) -> None:
subprocess.Popen(["open", str(path)])
@@ -0,0 +1,114 @@
from __future__ import annotations
from ._common import fetch_status_payload, open_path, queue_request, repo_root
try:
import pcbnew # type: ignore
import wx # type: ignore
except Exception: # pragma: no cover
pcbnew = None
wx = None
INTENTS = [
"board-review",
"erc-drc-assist",
"bom-footprint-audit",
"ecad-mcad-sync",
]
def _board_path() -> str:
if pcbnew is None:
return ""
try:
board = pcbnew.GetBoard()
if board is None:
return ""
return board.GetFileName() or ""
except Exception:
return ""
def _selection_summary() -> list[str]:
path = _board_path()
return [path] if path else []
class YiACADDialog(wx.Dialog): # type: ignore[misc]
def __init__(self) -> None:
super().__init__(None, title="YiACAD AI for KiCad", size=(540, 420))
panel = wx.Panel(self)
sizer = wx.BoxSizer(wx.VERTICAL)
self.intent = wx.Choice(panel, choices=INTENTS)
self.intent.SetSelection(0)
self.source = wx.TextCtrl(panel, value=_board_path(), style=wx.TE_READONLY)
self.prompt = wx.TextCtrl(panel, style=wx.TE_MULTILINE, value="Describe the KiCad task to queue for YiACAD.")
sizer.Add(wx.StaticText(panel, label="Intent"), 0, wx.ALL, 8)
sizer.Add(self.intent, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 8)
sizer.Add(wx.StaticText(panel, label="Board / source path"), 0, wx.ALL, 8)
sizer.Add(self.source, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 8)
sizer.Add(wx.StaticText(panel, label="Prompt"), 0, wx.ALL, 8)
sizer.Add(self.prompt, 1, wx.EXPAND | wx.LEFT | wx.RIGHT, 8)
button_row = wx.BoxSizer(wx.HORIZONTAL)
queue_btn = wx.Button(panel, label="Queue AI Request")
status_btn = wx.Button(panel, label="YiACAD Status")
artifacts_btn = wx.Button(panel, label="Open Artifacts")
close_btn = wx.Button(panel, label="Close")
queue_btn.Bind(wx.EVT_BUTTON, self.on_queue)
status_btn.Bind(wx.EVT_BUTTON, self.on_status)
artifacts_btn.Bind(wx.EVT_BUTTON, self.on_artifacts)
close_btn.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.ID_OK))
for button in (queue_btn, status_btn, artifacts_btn, close_btn):
button_row.Add(button, 0, wx.ALL, 6)
sizer.Add(button_row, 0, wx.ALIGN_RIGHT | wx.ALL, 8)
panel.SetSizer(sizer)
def on_queue(self, _event) -> None:
payload = queue_request(
"kicad",
self.intent.GetStringSelection(),
self.prompt.GetValue().strip(),
self.source.GetValue().strip(),
_selection_summary(),
)
wx.MessageBox(
f"Queued request:\n{payload.get('request_path', '')}",
"YiACAD",
wx.OK | wx.ICON_INFORMATION,
)
def on_status(self, _event) -> None:
payload = fetch_status_payload()
lines = payload.get("yiacad_status_excerpt") or []
summary = "\n".join(lines[:8]) if lines else "No YiACAD status snapshot yet."
latest_request = payload.get("latest_request") or "(none)"
wx.MessageBox(
f"Latest request:\n{latest_request}\n\nStatus:\n{summary}",
"YiACAD Status",
wx.OK | wx.ICON_INFORMATION,
)
def on_artifacts(self, _event) -> None:
open_path(repo_root() / "artifacts")
class YiACADActionPlugin(pcbnew.ActionPlugin if pcbnew is not None else object): # type: ignore[misc]
def defaults(self) -> None:
self.name = "YiACAD AI Bridge"
self.category = "Kill_LIFE AI-native"
self.description = "Queue KiCad AI tasks into YiACAD and inspect the latest CAD status."
self.show_toolbar_button = False
def Run(self) -> None:
if wx is None:
raise RuntimeError("wx is unavailable in the current KiCad runtime")
dialog = YiACADDialog()
dialog.ShowModal()
dialog.Destroy()
@@ -0,0 +1 @@
proof fixture for YiACAD backend proof
@@ -0,0 +1 @@
proof fixture for YiACAD backend proof
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
KICAD_PLUGIN_DIR="${KICAD_PLUGIN_DIR:-${HOME}/Library/Application Support/kicad/scripting/plugins}"
FREECAD_MOD_DIR="${FREECAD_MOD_DIR:-${HOME}/Library/Application Support/FreeCAD/Mod}"
KICAD_SURFACE="${ROOT_DIR}/.runtime-home/cad-ai-native-forks/kicad-ki/scripting/plugins/yiacad_kicad_plugin"
FREECAD_SURFACE="${ROOT_DIR}/.runtime-home/cad-ai-native-forks/freecad-ki/src/Mod/YiACADWorkbench"
mkdir -p "${KICAD_PLUGIN_DIR}" "${FREECAD_MOD_DIR}"
ln -sfn "${KICAD_SURFACE}" "${KICAD_PLUGIN_DIR}/yiacad_kicad_plugin"
ln -sfn "${FREECAD_SURFACE}" "${FREECAD_MOD_DIR}/YiACADWorkbench"
printf 'ROOT_DIR=%s\n' "${ROOT_DIR}"
printf 'KICAD_SURFACE=%s\n' "${KICAD_SURFACE}"
printf 'FREECAD_SURFACE=%s\n' "${FREECAD_SURFACE}"
printf 'KICAD_PLUGIN_DIR=%s\n' "${KICAD_PLUGIN_DIR}"
printf 'FREECAD_MOD_DIR=%s\n' "${FREECAD_MOD_DIR}"
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Shared request bridge for KiCad and FreeCAD AI-native helpers."""
from __future__ import annotations
import argparse
import json
from datetime import datetime
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
REQUEST_DIR = ROOT / "artifacts" / "cad-ai-requests"
YIACAD_STATUS = ROOT / "artifacts" / "cad-fusion" / "yiacad-fusion-last-status.md"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command", required=True)
req = sub.add_parser("request")
req.add_argument("--surface", choices=("kicad", "freecad"), required=True)
req.add_argument("--intent", required=True)
req.add_argument("--prompt", default="")
req.add_argument("--source-path", default="")
req.add_argument("--selection-json", default="[]")
sub.add_parser("status")
return parser.parse_args()
def ensure_request_dir() -> None:
REQUEST_DIR.mkdir(parents=True, exist_ok=True)
def selection_from_json(raw: str) -> list[Any]:
try:
payload = json.loads(raw)
except json.JSONDecodeError:
return [raw] if raw else []
if isinstance(payload, list):
return payload
return [payload]
def latest_request() -> Path | None:
ensure_request_dir()
files = sorted(REQUEST_DIR.glob("*.json"), key=lambda item: item.stat().st_mtime, reverse=True)
return files[0] if files else None
def latest_status_excerpt() -> list[str]:
if not YIACAD_STATUS.exists():
return []
lines = [line.rstrip() for line in YIACAD_STATUS.read_text(encoding="utf-8").splitlines() if line.strip()]
return lines[:12]
def command_request(args: argparse.Namespace) -> int:
ensure_request_dir()
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
request_path = REQUEST_DIR / f"{stamp}_{args.surface}_{args.intent}.json"
payload = {
"id": request_path.stem,
"created_at": datetime.now().isoformat(timespec="seconds"),
"surface": args.surface,
"intent": args.intent,
"prompt": args.prompt,
"source_path": args.source_path,
"selection": selection_from_json(args.selection_json),
"status_hint": str(YIACAD_STATUS),
}
request_path.write_text(json.dumps(payload, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
print(
json.dumps(
{
"status": "queued",
"request_path": str(request_path),
"surface": args.surface,
"intent": args.intent,
},
ensure_ascii=True,
)
)
return 0
def command_status(_: argparse.Namespace) -> int:
last_request = latest_request()
payload = {
"request_dir": str(REQUEST_DIR),
"latest_request": str(last_request) if last_request else "",
"yiacad_status": str(YIACAD_STATUS),
"yiacad_status_exists": YIACAD_STATUS.exists(),
"yiacad_status_excerpt": latest_status_excerpt(),
}
print(json.dumps(payload, ensure_ascii=True))
return 0
def main() -> int:
args = parse_args()
if args.command == "request":
return command_request(args)
if args.command == "status":
return command_status(args)
raise SystemExit(2)
if __name__ == "__main__":
raise SystemExit(main())
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Local YiACAD backend helpers shared by native shells, TUI and CAD utilities."""
from __future__ import annotations
import json
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
ARTIFACTS_ROOT = ROOT / "artifacts" / "cad-ai-native"
FUSION_STATUS = ROOT / "artifacts" / "cad-fusion" / "yiacad-fusion-last-status.md"
def utc_timestamp() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def resolve_path(value: str | Path | None) -> Path | None:
if not value:
return None
path = Path(value).expanduser()
try:
return path.resolve()
except FileNotFoundError:
return path.resolve(strict=False)
def first_existing_path(*candidates: str | Path | None) -> Path | None:
for candidate in candidates:
path = resolve_path(candidate)
if path and path.exists():
return path
return None
def infer_surface(
*,
board: str | Path | None = None,
schematic: str | Path | None = None,
freecad_document: str | Path | None = None,
) -> str:
has_board = bool(first_existing_path(board))
has_schematic = bool(first_existing_path(schematic))
has_freecad = bool(first_existing_path(freecad_document))
if has_board and not has_schematic and not has_freecad:
return "kicad-pcb"
if has_schematic and not has_board and not has_freecad:
return "kicad-sch"
if has_freecad and not has_board and not has_schematic:
return "freecad-workbench"
return "tui"
def build_context_ref(
*,
source_path: str | Path | None = None,
board: str | Path | None = None,
schematic: str | Path | None = None,
freecad_document: str | Path | None = None,
) -> str | None:
candidate = first_existing_path(board, schematic, freecad_document, source_path)
if not candidate:
for fallback in (board, schematic, freecad_document, source_path):
candidate = resolve_path(fallback)
if candidate:
break
if not candidate:
return None
workspace = candidate if candidate.is_dir() else candidate.parent
leaf = candidate.name if candidate.is_dir() else candidate.stem
return f"project:{workspace.name}/{leaf}"
def build_context_record(
surface: str,
*,
source_path: str | Path | None = None,
board: str | Path | None = None,
schematic: str | Path | None = None,
freecad_document: str | Path | None = None,
artifacts_dir: str | Path | None = None,
) -> dict:
source = resolve_path(source_path)
board_path = resolve_path(board)
schematic_path = resolve_path(schematic)
freecad_path = resolve_path(freecad_document)
artifacts_path = resolve_path(artifacts_dir)
return {
"component": "yiacad-context",
"generated_at": utc_timestamp(),
"surface": surface,
"context_ref": build_context_ref(
source_path=source,
board=board_path,
schematic=schematic_path,
freecad_document=freecad_path,
),
"paths": {
"source_path": str(source) if source else None,
"board": str(board_path) if board_path else None,
"schematic": str(schematic_path) if schematic_path else None,
"freecad_document": str(freecad_path) if freecad_path else None,
"artifacts_dir": str(artifacts_path) if artifacts_path else None,
},
"runtime": {
"root": str(ROOT),
"artifacts_root": str(ARTIFACTS_ROOT),
"fusion_status_path": str(FUSION_STATUS) if FUSION_STATUS.exists() else None,
},
}
def artifact_entry(path: str | Path, kind: str, label: str | None = None) -> dict:
resolved = resolve_path(path)
return {
"kind": kind,
"path": str(resolved) if resolved else str(path),
"label": label,
}
def output_status_from_returncodes(returncodes: list[int]) -> tuple[str, str]:
if not returncodes:
return ("blocked", "error")
if any(code not in (0, 5) for code in returncodes):
return ("blocked", "error")
if any(code == 5 for code in returncodes):
return ("degraded", "warning")
return ("done", "info")
def build_uiux_output(
*,
surface: str,
action: str,
execution_mode: str,
status: str,
severity: str,
summary: str,
details: str | None,
context_ref: str | None,
artifacts: list[dict],
next_steps: list[str],
latency_ms: int | None = None,
) -> dict:
return {
"component": "yiacad",
"surface": surface,
"action": action,
"execution_mode": execution_mode,
"status": status,
"severity": severity,
"summary": summary,
"details": details,
"generated_at": utc_timestamp(),
"context_ref": context_ref,
"provider": None,
"model": None,
"latency_ms": latency_ms,
"artifacts": artifacts,
"next_steps": next_steps,
}
def write_json(path: str | Path, payload: object) -> Path:
target = Path(path)
target.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return target
def write_context_record(path: str | Path, payload: dict) -> Path:
return write_json(path, payload)
def write_uiux_output(path: str | Path, payload: dict) -> Path:
return write_json(path, payload)
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""YiACAD backend client with service-first transport and direct fallback."""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SERVICE_SCRIPT = ROOT / "tools" / "cad" / "yiacad_backend_service.py"
NATIVE_OPS = ROOT / "tools" / "cad" / "yiacad_native_ops.py"
SERVICE_ARTIFACTS = ROOT / "artifacts" / "cad-ai-native" / "service"
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 38435
def ensure_dir(path: Path) -> Path:
path.mkdir(parents=True, exist_ok=True)
return path
def service_url(host: str, port: int, suffix: str) -> str:
return f"http://{host}:{port}{suffix}"
def http_json(url: str, payload: dict | None = None, timeout: float = 1.5) -> dict:
data = None
method = "GET"
headers = {}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
method = "POST"
headers["Content-Type"] = "application/json"
request = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
def service_health(host: str, port: int) -> dict | None:
try:
return http_json(service_url(host, port, "/health"))
except Exception: # noqa: BLE001
return None
def start_service(host: str, port: int) -> None:
ensure_dir(SERVICE_ARTIFACTS)
stamp = time.strftime("%Y%m%d_%H%M%S")
log_path = SERVICE_ARTIFACTS / f"backend_service_{stamp}.log"
with log_path.open("a", encoding="utf-8") as handle:
subprocess.Popen(
[sys.executable, str(SERVICE_SCRIPT), "--host", host, "--port", str(port)],
stdout=handle,
stderr=subprocess.STDOUT,
start_new_session=True,
)
def ensure_service(host: str, port: int) -> bool:
if service_health(host, port):
return True
start_service(host, port)
for _ in range(10):
time.sleep(0.25)
if service_health(host, port):
return True
return False
def direct_fallback(argv: list[str]) -> int:
proc = subprocess.run([sys.executable, str(NATIVE_OPS), *argv], text=True, capture_output=True)
if proc.stdout:
print(proc.stdout.strip())
elif proc.stderr:
print(proc.stderr.strip())
return proc.returncode
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="YiACAD backend client")
parser.add_argument("--host", default=DEFAULT_HOST, help="Service host")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Service port")
parser.add_argument("--json-output", action="store_true", help="Emit JSON output")
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("health", help="Check YiACAD backend service health")
status = subparsers.add_parser("status", help="YiACAD status")
status.add_argument("--source-path", default="")
erc = subparsers.add_parser("kicad-erc-drc", help="Run ERC/DRC through service")
erc.add_argument("--source-path", default="")
erc.add_argument("--board", default="")
erc.add_argument("--schematic", default="")
bom = subparsers.add_parser("bom-review", help="Run BOM review through service")
bom.add_argument("--source-path", default="")
bom.add_argument("--schematic", default="")
sync = subparsers.add_parser("ecad-mcad-sync", help="Run ECAD/MCAD sync through service")
sync.add_argument("--source-path", default="")
sync.add_argument("--board", default="")
sync.add_argument("--schematic", default="")
sync.add_argument("--freecad-document", default="")
return parser.parse_args()
def payload_from_args(args: argparse.Namespace) -> dict:
payload = {"command": args.command}
for key in ("source_path", "board", "schematic", "freecad_document"):
if hasattr(args, key):
payload[key] = getattr(args, key)
return payload
def main() -> int:
args = parse_args()
if args.command == "health":
payload = service_health(args.host, args.port)
if payload:
print(json.dumps(payload, indent=2, ensure_ascii=False))
return 0
return 1
payload = payload_from_args(args)
direct_argv = [args.command]
for key in ("source_path", "board", "schematic", "freecad_document"):
if key in payload and payload[key]:
direct_argv.extend([f"--{key.replace('_', '-')}", payload[key]])
if args.json_output:
direct_argv.append("--json-output")
if not ensure_service(args.host, args.port):
return direct_fallback(direct_argv)
try:
response = http_json(service_url(args.host, args.port, "/run"), payload)
if args.json_output:
print(json.dumps(response, indent=2, ensure_ascii=False))
else:
print(response.get("summary", json.dumps(response, ensure_ascii=False)))
return 0 if response.get("status") != "blocked" else 1
except urllib.error.HTTPError:
return direct_fallback(direct_argv)
except Exception: # noqa: BLE001
return direct_fallback(direct_argv)
if __name__ == "__main__":
raise SystemExit(main())
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""YiACAD local backend service for native surfaces and TUI clients."""
from __future__ import annotations
import argparse
import contextlib
import io
import json
import os
import sys
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
try:
import yiacad_native_ops as native_ops
from yiacad_backend import artifact_entry, build_uiux_output, utc_timestamp, write_json
except ImportError:
from tools.cad import yiacad_native_ops as native_ops
from tools.cad.yiacad_backend import artifact_entry, build_uiux_output, utc_timestamp, write_json
ROOT = Path(__file__).resolve().parents[2]
SERVICE_ARTIFACTS = ROOT / "artifacts" / "cad-ai-native" / "service"
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 38435
def ensure_dir(path: Path) -> Path:
path.mkdir(parents=True, exist_ok=True)
return path
def uiux_contract_from_failure(command: str, error_text: str, request_path: Path | None = None) -> dict:
artifacts = []
if request_path:
artifacts.append(artifact_entry(request_path, "evidence", "YiACAD backend request"))
return build_uiux_output(
surface="service",
action=command,
execution_mode="background",
status="blocked",
severity="error",
summary="YiACAD backend service could not fulfill the request.",
details=error_text,
context_ref=None,
artifacts=artifacts,
next_steps=["inspect backend request", "retry the action", "fallback to direct runner"],
latency_ms=None,
)
def build_args(command: str, payload: dict) -> argparse.Namespace:
common = {"command": command, "json_output": True}
if command == "status":
return argparse.Namespace(**common)
if command == "kicad-erc-drc":
return argparse.Namespace(
**common,
source_path=payload.get("source_path", ""),
board=payload.get("board", ""),
schematic=payload.get("schematic", ""),
)
if command == "bom-review":
return argparse.Namespace(
**common,
source_path=payload.get("source_path", ""),
schematic=payload.get("schematic", ""),
)
if command == "ecad-mcad-sync":
return argparse.Namespace(
**common,
source_path=payload.get("source_path", ""),
board=payload.get("board", ""),
schematic=payload.get("schematic", ""),
freecad_document=payload.get("freecad_document", ""),
)
raise KeyError(f"Unsupported YiACAD command: {command}")
def dispatch_command(command: str, payload: dict) -> tuple[int, dict]:
mapping = {
"status": native_ops.command_status,
"kicad-erc-drc": native_ops.command_kicad_erc_drc,
"bom-review": native_ops.command_bom_review,
"ecad-mcad-sync": native_ops.command_ecad_mcad_sync,
}
handler = mapping[command]
args = build_args(command, payload)
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
rc = handler(args)
body = stdout.getvalue().strip()
if body:
try:
parsed = json.loads(body)
if isinstance(parsed, dict):
return rc, parsed
except json.JSONDecodeError:
pass
contract = build_uiux_output(
surface="service",
action=command,
execution_mode="background",
status="done" if rc == 0 else "blocked",
severity="info" if rc == 0 else "error",
summary=f"YiACAD backend service executed `{command}`.",
details=body or "No structured response body was returned.",
context_ref=None,
artifacts=[],
next_steps=["inspect service response", "retry via direct runner if needed"],
latency_ms=None,
)
return rc, contract
class YiacadBackendHandler(BaseHTTPRequestHandler):
server_version = "YiACADBackend/0.1"
def _json_response(self, status_code: int, payload: dict) -> None:
raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status_code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def do_GET(self) -> None:
if self.path != "/health":
self._json_response(404, {"status": "not_found"})
return
payload = {
"status": "done",
"component": "yiacad-backend-service",
"generated_at": utc_timestamp(),
"pid": os.getpid(),
"host": self.server.server_address[0],
"port": self.server.server_address[1],
"artifacts_dir": str(SERVICE_ARTIFACTS),
}
write_json(SERVICE_ARTIFACTS / "latest_health.json", payload)
self._json_response(200, payload)
def do_POST(self) -> None:
if self.path != "/run":
self._json_response(404, {"status": "not_found"})
return
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
started_at = time.time()
request_path = ensure_dir(SERVICE_ARTIFACTS) / f"request_{int(started_at)}.json"
try:
payload = json.loads(raw)
write_json(request_path, payload)
command = payload["command"]
rc, response = dispatch_command(command, payload)
response["service"] = {
"mode": "http",
"generated_at": utc_timestamp(),
"latency_ms": int((time.time() - started_at) * 1000),
}
response.setdefault("artifacts", []).append(
artifact_entry(request_path, "evidence", "YiACAD backend request")
)
write_json(SERVICE_ARTIFACTS / "latest_response.json", response)
self._json_response(200 if rc == 0 else 207, response)
except Exception as exc: # noqa: BLE001
response = uiux_contract_from_failure("service.dispatch", str(exc), request_path)
response["service"] = {
"mode": "http",
"generated_at": utc_timestamp(),
"latency_ms": int((time.time() - started_at) * 1000),
}
write_json(SERVICE_ARTIFACTS / "latest_response.json", response)
self._json_response(500, response)
def log_message(self, format: str, *args) -> None: # noqa: A003
log_path = ensure_dir(SERVICE_ARTIFACTS) / "server.log"
with log_path.open("a", encoding="utf-8") as handle:
handle.write("%s - %s\n" % (self.log_date_time_string(), format % args))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="YiACAD backend HTTP service")
parser.add_argument("--host", default=DEFAULT_HOST, help="Bind host")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Bind port")
return parser.parse_args()
def main() -> int:
args = parse_args()
ensure_dir(SERVICE_ARTIFACTS)
server = ThreadingHTTPServer((args.host, args.port), YiacadBackendHandler)
write_json(
SERVICE_ARTIFACTS / "latest_server.json",
{
"status": "done",
"component": "yiacad-backend-service",
"generated_at": utc_timestamp(),
"pid": os.getpid(),
"host": args.host,
"port": args.port,
"artifacts_dir": str(SERVICE_ARTIFACTS),
},
)
try:
server.serve_forever()
except KeyboardInterrupt:
return 0
finally:
server.server_close()
if __name__ == "__main__":
raise SystemExit(main())
+287
View File
@@ -0,0 +1,287 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
BASE_DIR="${KILL_LIFE_CAD_AI_BASE_DIR:-${ROOT_DIR}/.runtime-home/cad-ai-native-forks}"
DEFAULT_OWNER="${KILL_LIFE_FORK_OWNER:-electron-rare}"
DEFAULT_BRANCH="${KILL_LIFE_FORK_BRANCH:-kill-life-ai-native}"
LOG_DIR="${ROOT_DIR}/artifacts/cad-fusion"
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
LOT_ID="yiacad-fusion"
OWNER_TEAM="CAD-Fusion"
OWNER_AGENT="CAD-Fusion"
LAST_LOG_FILE="${LOG_DIR}/yiacad-fusion-last.log"
LAST_STATUS_FILE="${LOG_DIR}/yiacad-fusion-last-status.md"
ACTION="prepare"
YES=0
DAYS_KEEP=14
OWNER="$DEFAULT_OWNER"
BRANCH="$DEFAULT_BRANCH"
usage() {
cat <<'EOF_USAGE'
Usage: tools/cad/yiacad_fusion_lot.sh [options]
Lane d'orchestration YiACAD (KiCad + FreeCAD + OpenSCAD IA-native).
Options:
--action <prepare|smoke|status|logs|clean-logs>
Action. Défaut: prepare
--base-dir DIR Dossier de base des clones forks (défaut: .runtime-home/cad-ai-native-forks)
--owner OWNER Propriétaire du fork GitHub (défaut: electron-rare)
--branch BRANCH Branche IA-native (défaut: kill-life-ai-native)
--days N Rétention des logs pour clean-logs (défaut: 14)
--yes Confirmer suppression (clean-logs)
-h, --help Aide
Examples:
bash tools/cad/yiacad_fusion_lot.sh --action prepare
bash tools/cad/yiacad_fusion_lot.sh --action smoke
bash tools/cad/yiacad_fusion_lot.sh --action status
bash tools/cad/yiacad_fusion_lot.sh --action logs
bash tools/cad/yiacad_fusion_lot.sh --action clean-logs --days 7 --yes
EOF_USAGE
}
ensure_dirs() {
mkdir -p "$LOG_DIR"
}
log_file() {
printf '%s\n' "${LOG_DIR}/yiacad-fusion_${TIMESTAMP}.log"
}
status_file() {
printf '%s\n' "${LOG_DIR}/yiacad-fusion-status_${TIMESTAMP}.md"
}
run_and_log() {
local label="$1"
shift
local lf
local rc=0
lf="$(log_file)"
printf '[%s] START %s\n' "$(date '+%Y-%m-%d %H:%M:%S %z')" "$label" >>"$lf"
if "$@" >>"$lf" 2>&1; then
printf '[%s] OK %s\n' "$(date '+%Y-%m-%d %H:%M:%S %z')" "$label" >>"$lf"
return 0
else
rc="$?"
fi
printf '[%s] FAIL %s (code=%s)\n' "$(date '+%Y-%m-%d %H:%M:%S %z')" "$label" "$rc" >>"$lf"
return "$rc"
}
write_repo_snapshot() {
local repo_name="$1"
local sf="$2"
local repo_dir="$BASE_DIR/${repo_name}-ki"
{
printf '### %s\n' "$repo_name"
if [[ -d "$repo_dir/.git" ]]; then
local remote default_branch local_branch head
remote="$(cd "$repo_dir" && git remote -v | awk '$1 == "origin" { print $2; exit }')"
default_branch="$(cd "$repo_dir" && git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's#refs/remotes/origin/##')"
local_branch="$(cd "$repo_dir" && git rev-parse --abbrev-ref HEAD)"
head="$(cd "$repo_dir" && git rev-parse --short HEAD)"
printf -- '- status: ok\n'
printf -- '- local_branch: %s\n' "$local_branch"
printf -- '- head: %s\n' "$head"
printf -- '- remote_head: %s\n' "${default_branch:-unknown}"
printf -- '- origin: %s\n' "${remote:-unknown}"
printf -- '- remotes:\n'
while IFS= read -r line; do
printf -- ' - %s\n' "$line"
done < <(cd "$repo_dir" && git remote -v | awk '{print $1 " -> " $2}' | sort -u)
return
fi
printf '- status: missing\n'
} >>"$sf"
}
write_status_snapshot() {
local sf
local manifest_path="${BASE_DIR}/manifest.md"
sf="$(status_file)"
{
printf '# YiACAD status\n\n'
printf -- '- lot_id: %s\n' "$LOT_ID"
printf -- '- owner_team: %s\n' "$OWNER_TEAM"
printf -- '- owner_agent: %s\n' "$OWNER_AGENT"
printf -- '- root: %s\n' "$ROOT_DIR"
printf -- '- base_dir: %s\n' "$BASE_DIR"
printf -- '- owner: %s\n' "$OWNER"
printf -- '- branch: %s\n' "$BRANCH"
printf -- '- manifest: %s\n' "$manifest_path"
if [[ -f "$manifest_path" ]]; then
printf -- '- manifest_state: present\n'
else
printf -- '- manifest_state: missing\n'
fi
printf -- '- log_dir: %s\n' "$LOG_DIR"
printf -- '- generated_at: %s\n' "$(date '+%Y-%m-%d %H:%M:%S %z')"
printf '\n'
} >"$sf"
write_repo_snapshot kicad "$sf"
write_repo_snapshot freecad "$sf"
cp "$sf" "$LAST_STATUS_FILE"
}
run_prepare() {
ensure_dirs
local args=(
bash "$ROOT_DIR/tools/cad/ai_native_forks.sh"
--owner "$OWNER"
--branch "$BRANCH"
--base-dir "$BASE_DIR"
--projects "kicad freecad"
)
run_and_log "YiACAD prepare forks" "${args[@]}"
write_status_snapshot
cp "$(log_file)" "$LAST_LOG_FILE"
cat "$LAST_STATUS_FILE"
}
run_smoke() {
ensure_dirs
local -a smoke_failures=()
run_and_log "CAD stack doctor" bash "$ROOT_DIR/tools/hw/cad_stack.sh" doctor-mcp || smoke_failures+=("CAD stack doctor")
run_and_log "KiCad MCP host doctor" bash "$ROOT_DIR/tools/hw/run_kicad_mcp.sh" --doctor || smoke_failures+=("KiCad MCP host doctor")
run_and_log "KiCad MCP host smoke" python3 "$ROOT_DIR/tools/hw/kicad_host_mcp_smoke.py" --json --quick || smoke_failures+=("KiCad MCP host smoke")
run_and_log "FreeCAD MCP smoke" python3 "$ROOT_DIR/tools/freecad_mcp_smoke.py" --json --quick || smoke_failures+=("FreeCAD MCP smoke")
run_and_log "OpenSCAD MCP smoke" python3 "$ROOT_DIR/tools/openscad_mcp_smoke.py" --json --quick || smoke_failures+=("OpenSCAD MCP smoke")
run_and_log "FreeCAD MCP doctor" bash "$ROOT_DIR/tools/run_freecad_mcp.sh" --doctor || smoke_failures+=("FreeCAD MCP doctor")
run_and_log "OpenSCAD MCP doctor" bash "$ROOT_DIR/tools/run_openscad_mcp.sh" --doctor || smoke_failures+=("OpenSCAD MCP doctor")
write_status_snapshot
cp "$(log_file)" "$LAST_LOG_FILE"
if [[ "${#smoke_failures[@]}" -gt 0 ]]; then
{
printf '\n## smoke_failures\n'
for failure in "${smoke_failures[@]}"; do
printf -- '- %s\n' "$failure"
done
} >>"$LAST_STATUS_FILE"
cat "$LAST_STATUS_FILE"
return 1
fi
cat "$LAST_STATUS_FILE"
}
show_status() {
ensure_dirs
write_status_snapshot
cat "$LAST_STATUS_FILE"
echo
if [[ -f "$LAST_LOG_FILE" ]]; then
echo "Last log: $LAST_LOG_FILE"
else
echo "No run log yet."
fi
}
list_logs() {
echo "=== YiACAD logs (artifacts/cad-fusion) ==="
local files
files="$(
find "$LOG_DIR" -maxdepth 1 -type f \
\( -name 'yiacad-fusion_*.log' -o -name 'yiacad-fusion-status_*.md' -o -name 'yiacad-fusion-last.log' -o -name 'yiacad-fusion-last-status.md' \) \
| sort | tail -n 40
)"
if [[ -z "$files" ]]; then
echo "Aucun log YiACAD trouvé dans $LOG_DIR"
return 0
fi
printf '%s\n' "$files"
}
clean_logs() {
local days="${1:-$DAYS_KEEP}"
if [[ "$days" -lt 1 ]]; then
echo "--days doit être >= 1"
return 1
fi
local stale_count
stale_count="$(find "$LOG_DIR" -maxdepth 1 -type f \( -name 'yiacad-fusion_*.log' -o -name 'yiacad-fusion-status_*.md' \) -mtime +"$days" | wc -l | tr -d ' ')"
if [[ "$stale_count" == "0" ]]; then
echo "No log candidate older than ${days} day(s)."
return 0
fi
if [[ "$YES" -ne 1 ]]; then
echo "${stale_count} log files older than ${days} day(s) would be deleted. Re-run with --yes."
return 0
fi
find "$LOG_DIR" -maxdepth 1 -type f \( -name 'yiacad-fusion_*.log' -o -name 'yiacad-fusion-status_*.md' \) -mtime +"$days" -delete
echo "Deleted ${stale_count} logs older than ${days} day(s)."
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--base-dir)
BASE_DIR="${2:-}"
shift 2
;;
--owner)
OWNER="${2:-}"
shift 2
;;
--branch)
BRANCH="${2:-}"
shift 2
;;
--days)
DAYS_KEEP="${2:-14}"
shift 2
;;
--yes)
YES=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Option inconnue: $1" >&2
usage
exit 2
;;
esac
done
ensure_dirs
case "$ACTION" in
prepare)
run_prepare
;;
smoke)
run_smoke
;;
status)
show_status
;;
logs)
list_logs
;;
clean-logs)
clean_logs "$DAYS_KEEP"
;;
*)
echo "Action inconnue: $ACTION" >&2
usage
exit 1
;;
esac
+680
View File
@@ -0,0 +1,680 @@
#!/usr/bin/env python3
"""Concrete YiACAD native CAD utilities for KiCad and FreeCAD surfaces."""
from __future__ import annotations
import argparse
import csv
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Iterable
try:
from yiacad_backend import (
artifact_entry,
build_context_record,
build_uiux_output,
infer_surface,
output_status_from_returncodes,
write_context_record,
write_uiux_output,
)
except ImportError:
from tools.cad.yiacad_backend import (
artifact_entry,
build_context_record,
build_uiux_output,
infer_surface,
output_status_from_returncodes,
write_context_record,
write_uiux_output,
)
ROOT = Path(__file__).resolve().parents[2]
ARTIFACTS_ROOT = ROOT / "artifacts" / "cad-ai-native"
FUSION_STATUS = ROOT / "artifacts" / "cad-fusion" / "yiacad-fusion-last-status.md"
def now_stamp() -> str:
return time.strftime("%Y%m%dT%H%M%S")
def ensure_dir(path: Path) -> Path:
path.mkdir(parents=True, exist_ok=True)
return path
def make_run_dir(label: str) -> Path:
return ensure_dir(ARTIFACTS_ROOT / f"{now_stamp()}-{label}")
def write_text(path: Path, text: str) -> None:
path.write_text(text, encoding="utf-8")
def write_json(path: Path, payload: object) -> None:
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def run(cmd: list[str], *, cwd: Path | None = None, ok_codes: Iterable[int] = (0,)) -> dict:
proc = subprocess.run(
cmd,
cwd=str(cwd) if cwd else None,
text=True,
capture_output=True,
)
payload = {
"cmd": cmd,
"returncode": proc.returncode,
"stdout": proc.stdout,
"stderr": proc.stderr,
"ok": proc.returncode in set(ok_codes),
}
return payload
def resolve_kicad_cli() -> str:
mac = Path("/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli")
if mac.exists():
return str(mac)
return "kicad-cli"
def resolve_freecad_cmd() -> str | None:
candidates = (
Path("/Applications/FreeCAD.app/Contents/MacOS/FreeCADCmd"),
Path("/Applications/FreeCAD.app/Contents/MacOS/FreeCAD"),
)
for candidate in candidates:
if candidate.exists():
return str(candidate)
for name in ("FreeCADCmd", "freecadcmd", "FreeCAD"):
found = shutil.which(name)
if found:
return found
return None
def guess_board_from_source(source_path: str | None) -> Path | None:
if not source_path:
return None
source = Path(source_path).expanduser().resolve()
if source.suffix == ".kicad_pcb" and source.exists():
return source
base = source.with_suffix(".kicad_pcb")
if base.exists():
return base
for candidate in sorted(source.parent.glob("*.kicad_pcb")):
return candidate.resolve()
return None
def guess_schematic_from_source(source_path: str | None) -> Path | None:
if not source_path:
return None
source = Path(source_path).expanduser().resolve()
if source.suffix == ".kicad_sch" and source.exists():
return source
base = source.with_suffix(".kicad_sch")
if base.exists():
return base
for candidate in sorted(source.parent.glob("*.kicad_sch")):
return candidate.resolve()
return None
def guess_freecad_document(source_path: str | None) -> Path | None:
if not source_path:
return None
source = Path(source_path).expanduser().resolve()
if source.suffix == ".FCStd" and source.exists():
return source
for candidate in sorted(source.parent.glob("*.FCStd")):
return candidate.resolve()
return None
def latest_native_summary() -> str:
if not ARTIFACTS_ROOT.exists():
return "No YiACAD native artifact has been generated yet."
runs = sorted(ARTIFACTS_ROOT.iterdir(), reverse=True)
for run_dir in runs:
summary = run_dir / "summary.md"
if summary.exists():
return summary.read_text(encoding="utf-8").strip()
return "No YiACAD native summary is available yet."
def print_contract_or_fallback(args: argparse.Namespace, payload: dict, fallback: str | Path) -> None:
if getattr(args, "json_output", False):
print(json.dumps(payload, indent=2, ensure_ascii=False))
else:
print(str(fallback))
def command_status(args: argparse.Namespace) -> int:
lines = ["# YiACAD Native Status", ""]
if FUSION_STATUS.exists():
lines += ["## Fusion lane", "", FUSION_STATUS.read_text(encoding="utf-8").strip(), ""]
else:
lines += ["## Fusion lane", "", "No fusion status snapshot found.", ""]
lines += ["## Native surfaces", "", latest_native_summary(), ""]
status = "done" if FUSION_STATUS.exists() or ARTIFACTS_ROOT.exists() else "degraded"
severity = "info" if status == "done" else "warning"
output_payload = build_uiux_output(
surface="tui",
action="status.surface",
execution_mode="batch",
status=status,
severity=severity,
summary="YiACAD native status snapshot generated.",
details="Fusion lane snapshot and latest native summaries were collected.",
context_ref=None,
artifacts=[
artifact_entry(FUSION_STATUS, "report", "YiACAD fusion status"),
]
if FUSION_STATUS.exists()
else [],
next_steps=[
"open artifacts",
"review latest native summary",
"continue with backend or UX lot",
],
latency_ms=None,
)
if getattr(args, "json_output", False):
print(json.dumps(output_payload, indent=2, ensure_ascii=False))
return 0
print("\n".join(lines).strip())
return 0
def command_kicad_erc_drc(args: argparse.Namespace) -> int:
started_at = time.time()
board = Path(args.board).expanduser().resolve() if args.board else guess_board_from_source(args.source_path)
schematic = (
Path(args.schematic).expanduser().resolve() if args.schematic else guess_schematic_from_source(args.source_path)
)
run_dir = make_run_dir("kicad-erc-drc")
surface = infer_surface(board=board, schematic=schematic)
context = build_context_record(
surface,
source_path=args.source_path,
board=board,
schematic=schematic,
artifacts_dir=run_dir,
)
context_path = write_context_record(run_dir / "context.json", context)
payload: dict[str, object] = {
"action": "kicad-erc-drc",
"board": str(board) if board else "",
"schematic": str(schematic) if schematic else "",
"artifacts_dir": str(run_dir),
"context": context,
"steps": [],
}
if not board and not schematic:
payload["error"] = "No KiCad board or schematic could be resolved."
output_payload = build_uiux_output(
surface=surface,
action="review.erc_drc",
execution_mode="batch",
status="blocked",
severity="error",
summary="YiACAD could not resolve a KiCad board or schematic.",
details="Provide --board, --schematic or a valid --source-path with project files present.",
context_ref=context["context_ref"],
artifacts=[
artifact_entry(context_path, "evidence", "YiACAD context record"),
artifact_entry(run_dir / "result.json", "evidence", "YiACAD raw payload"),
],
next_steps=[
"provide --board or --schematic",
"rerun with a loaded KiCad project",
],
latency_ms=int((time.time() - started_at) * 1000),
)
output_path = write_uiux_output(run_dir / "uiux_output.json", output_payload)
payload["uiux_output"] = str(output_path)
write_json(run_dir / "result.json", payload)
print_contract_or_fallback(args, output_payload, run_dir / "result.json")
return 2
kicad_cli = resolve_kicad_cli()
summary_lines = ["# YiACAD ERC/DRC Assist", ""]
rc_final = 0
if schematic:
erc_path = run_dir / "erc.json"
step = run(
[
kicad_cli,
"sch",
"erc",
"--format",
"json",
"--severity-all",
"--exit-code-violations",
"--output",
str(erc_path),
str(schematic),
],
ok_codes=(0, 5),
)
write_text(run_dir / "erc.stdout.txt", step["stdout"])
write_text(run_dir / "erc.stderr.txt", step["stderr"])
payload["steps"].append(step)
summary_lines += [f"- ERC: `{erc_path}` (rc={step['returncode']})"]
if not step["ok"]:
rc_final = step["returncode"]
if board:
drc_path = run_dir / "drc.json"
step = run(
[
kicad_cli,
"pcb",
"drc",
"--format",
"json",
"--severity-all",
"--exit-code-violations",
"--output",
str(drc_path),
str(board),
],
ok_codes=(0, 5),
)
write_text(run_dir / "drc.stdout.txt", step["stdout"])
write_text(run_dir / "drc.stderr.txt", step["stderr"])
payload["steps"].append(step)
summary_lines += [f"- DRC: `{drc_path}` (rc={step['returncode']})"]
if not step["ok"] and rc_final == 0:
rc_final = step["returncode"]
summary_lines += ["", f"- Board: `{board}`" if board else "- Board: unresolved"]
summary_lines += [f"- Schematic: `{schematic}`" if schematic else "- Schematic: unresolved"]
write_text(run_dir / "summary.md", "\n".join(summary_lines) + "\n")
payload["summary"] = str(run_dir / "summary.md")
status, severity = output_status_from_returncodes([step["returncode"] for step in payload["steps"]])
next_steps = {
"done": ["open generated reports", "continue with BOM review or sync"],
"degraded": ["open review center", "inspect generated reports", "rerun after fixes"],
"blocked": ["verify KiCad CLI and project paths", "rerun with project loaded"],
}[status]
artifacts = [
artifact_entry(run_dir / "summary.md", "report", "YiACAD ERC/DRC summary"),
artifact_entry(context_path, "evidence", "YiACAD context record"),
artifact_entry(run_dir / "result.json", "evidence", "YiACAD raw payload"),
]
if (run_dir / "erc.json").exists():
artifacts.append(artifact_entry(run_dir / "erc.json", "report", "ERC JSON report"))
if (run_dir / "drc.json").exists():
artifacts.append(artifact_entry(run_dir / "drc.json", "report", "DRC JSON report"))
for name, label in (
("erc.stdout.txt", "ERC stdout"),
("erc.stderr.txt", "ERC stderr"),
("drc.stdout.txt", "DRC stdout"),
("drc.stderr.txt", "DRC stderr"),
):
path = run_dir / name
if path.exists():
artifacts.append(artifact_entry(path, "log", label))
output_payload = build_uiux_output(
surface=surface,
action="review.erc_drc",
execution_mode="batch",
status=status,
severity=severity,
summary="YiACAD ERC/DRC run completed.",
details=f"Board={board or 'unresolved'} | Schematic={schematic or 'unresolved'} | Artifacts={run_dir}",
context_ref=context["context_ref"],
artifacts=artifacts,
next_steps=next_steps,
latency_ms=int((time.time() - started_at) * 1000),
)
output_path = write_uiux_output(run_dir / "uiux_output.json", output_payload)
payload["uiux_output"] = str(output_path)
write_json(run_dir / "result.json", payload)
print_contract_or_fallback(args, output_payload, run_dir / "summary.md")
return rc_final
def command_bom_review(args: argparse.Namespace) -> int:
started_at = time.time()
schematic = (
Path(args.schematic).expanduser().resolve() if args.schematic else guess_schematic_from_source(args.source_path)
)
run_dir = make_run_dir("bom-review")
surface = infer_surface(schematic=schematic)
context = build_context_record(
surface,
source_path=args.source_path,
schematic=schematic,
artifacts_dir=run_dir,
)
context_path = write_context_record(run_dir / "context.json", context)
if not schematic:
payload = {
"action": "bom-review",
"error": "No KiCad schematic could be resolved.",
"artifacts_dir": str(run_dir),
"context": context,
}
output_payload = build_uiux_output(
surface=surface,
action="review.bom",
execution_mode="batch",
status="blocked",
severity="error",
summary="YiACAD could not resolve a KiCad schematic for BOM review.",
details="Provide --schematic or a valid --source-path with .kicad_sch present.",
context_ref=context["context_ref"],
artifacts=[
artifact_entry(context_path, "evidence", "YiACAD context record"),
artifact_entry(run_dir / "result.json", "evidence", "YiACAD raw payload"),
],
next_steps=[
"provide --schematic",
"rerun with a loaded KiCad project",
],
latency_ms=int((time.time() - started_at) * 1000),
)
output_path = write_uiux_output(run_dir / "uiux_output.json", output_payload)
payload["uiux_output"] = str(output_path)
write_json(run_dir / "result.json", payload)
print_contract_or_fallback(args, output_payload, run_dir / "result.json")
return 2
kicad_cli = resolve_kicad_cli()
bom_path = run_dir / "bom.csv"
step = run(
[kicad_cli, "sch", "export", "bom", "--output", str(bom_path), str(schematic)],
ok_codes=(0,),
)
write_text(run_dir / "bom.stdout.txt", step["stdout"])
write_text(run_dir / "bom.stderr.txt", step["stderr"])
row_count = 0
columns: list[str] = []
blank_counts: dict[str, int] = {}
if bom_path.exists():
with bom_path.open("r", encoding="utf-8", newline="") as handle:
reader = csv.DictReader(handle)
columns = reader.fieldnames or []
blank_counts = {column: 0 for column in columns}
for row in reader:
row_count += 1
for column in columns:
if not (row.get(column) or "").strip():
blank_counts[column] += 1
summary_lines = [
"# YiACAD BOM Review",
"",
f"- Schematic: `{schematic}`",
f"- BOM: `{bom_path}`",
f"- Rows: `{row_count}`",
"",
"## Empty field counts",
"",
]
if columns:
for column in columns:
summary_lines.append(f"- `{column}`: {blank_counts.get(column, 0)}")
else:
summary_lines.append("- No BOM columns were parsed.")
write_text(run_dir / "summary.md", "\n".join(summary_lines) + "\n")
payload = {
"action": "bom-review",
"schematic": str(schematic),
"bom": str(bom_path),
"rows": row_count,
"columns": columns,
"blank_counts": blank_counts,
"step": step,
"summary": str(run_dir / "summary.md"),
"context": context,
}
status, severity = output_status_from_returncodes([step["returncode"]])
next_steps = {
"done": ["open BOM summary", "inspect blank field counts"],
"degraded": ["open BOM summary", "inspect blank field counts"],
"blocked": ["verify KiCad CLI and schematic path", "rerun BOM export"],
}[status]
artifacts = [
artifact_entry(run_dir / "summary.md", "report", "YiACAD BOM review summary"),
artifact_entry(context_path, "evidence", "YiACAD context record"),
artifact_entry(run_dir / "result.json", "evidence", "YiACAD raw payload"),
]
if bom_path.exists():
artifacts.append(artifact_entry(bom_path, "export", "BOM CSV export"))
for name, label in (("bom.stdout.txt", "BOM stdout"), ("bom.stderr.txt", "BOM stderr")):
path = run_dir / name
if path.exists():
artifacts.append(artifact_entry(path, "log", label))
output_payload = build_uiux_output(
surface=surface,
action="review.bom",
execution_mode="batch",
status=status,
severity=severity,
summary="YiACAD BOM review completed.",
details=f"Schematic={schematic} | Rows={row_count} | Artifacts={run_dir}",
context_ref=context["context_ref"],
artifacts=artifacts,
next_steps=next_steps,
latency_ms=int((time.time() - started_at) * 1000),
)
output_path = write_uiux_output(run_dir / "uiux_output.json", output_payload)
payload["uiux_output"] = str(output_path)
write_json(run_dir / "result.json", payload)
print_contract_or_fallback(args, output_payload, run_dir / "summary.md")
return step["returncode"]
def export_freecad_document(document_path: Path, output_path: Path, run_dir: Path) -> dict:
freecad_cmd = resolve_freecad_cmd()
if not freecad_cmd:
return {
"cmd": [],
"returncode": 127,
"stdout": "",
"stderr": "FreeCAD command runtime not found.",
"ok": False,
}
script_path = run_dir / "freecad_export_step.py"
write_text(
script_path,
"\n".join(
[
"import sys",
"import FreeCAD",
"import Import",
"",
"document_path = sys.argv[1]",
"output_path = sys.argv[2]",
"doc = FreeCAD.openDocument(document_path)",
"doc.recompute()",
"Import.export(doc.Objects, output_path)",
'print(output_path)',
]
)
+ "\n",
)
return run([freecad_cmd, str(script_path), str(document_path), str(output_path)], ok_codes=(0,))
def command_ecad_mcad_sync(args: argparse.Namespace) -> int:
started_at = time.time()
board = Path(args.board).expanduser().resolve() if args.board else guess_board_from_source(args.source_path)
schematic = (
Path(args.schematic).expanduser().resolve() if args.schematic else guess_schematic_from_source(args.source_path)
)
freecad_document = (
Path(args.freecad_document).expanduser().resolve()
if args.freecad_document
else guess_freecad_document(args.source_path)
)
run_dir = make_run_dir("ecad-mcad-sync")
surface = infer_surface(board=board, schematic=schematic, freecad_document=freecad_document)
context = build_context_record(
surface,
source_path=args.source_path,
board=board,
schematic=schematic,
freecad_document=freecad_document,
artifacts_dir=run_dir,
)
context_path = write_context_record(run_dir / "context.json", context)
kicad_cli = resolve_kicad_cli()
sync_payload: dict[str, object] = {
"action": "ecad-mcad-sync",
"board": str(board) if board else "",
"schematic": str(schematic) if schematic else "",
"freecad_document": str(freecad_document) if freecad_document else "",
"artifacts_dir": str(run_dir),
"context": context,
"steps": [],
}
summary_lines = ["# YiACAD ECAD/MCAD Sync", ""]
rc_final = 0
if board:
board_step = run_dir / f"{board.stem}.step"
step = run(
[
kicad_cli,
"pcb",
"export",
"step",
"--output",
str(board_step),
str(board),
],
ok_codes=(0,),
)
write_text(run_dir / "kicad_step.stdout.txt", step["stdout"])
write_text(run_dir / "kicad_step.stderr.txt", step["stderr"])
sync_payload["steps"].append(step)
summary_lines.append(f"- KiCad STEP export: `{board_step}` (rc={step['returncode']})")
if not step["ok"]:
rc_final = step["returncode"]
if freecad_document:
freecad_step = run_dir / f"{freecad_document.stem}.step"
step = export_freecad_document(freecad_document, freecad_step, run_dir)
write_text(run_dir / "freecad_step.stdout.txt", step["stdout"])
write_text(run_dir / "freecad_step.stderr.txt", step["stderr"])
sync_payload["steps"].append(step)
summary_lines.append(f"- FreeCAD STEP export: `{freecad_step}` (rc={step['returncode']})")
if not step["ok"] and rc_final == 0:
rc_final = step["returncode"]
if schematic:
summary_lines.append(f"- Linked schematic: `{schematic}`")
if board:
summary_lines.append(f"- Linked board: `{board}`")
if freecad_document:
summary_lines.append(f"- Linked FreeCAD document: `{freecad_document}`")
if not board and not freecad_document:
summary_lines.append("- No board or FreeCAD document could be resolved.")
rc_final = rc_final or 2
write_text(run_dir / "summary.md", "\n".join(summary_lines) + "\n")
sync_payload["summary"] = str(run_dir / "summary.md")
status, severity = output_status_from_returncodes([step["returncode"] for step in sync_payload["steps"]])
if not board and not freecad_document:
status, severity = ("blocked", "error")
next_steps = {
"done": ["open exported STEP artifacts", "continue with physical fit review"],
"degraded": ["inspect generated exports", "rerun with both ECAD and MCAD files present"],
"blocked": ["verify KiCad and FreeCAD project files", "rerun sync with complete context"],
}[status]
artifacts = [
artifact_entry(run_dir / "summary.md", "report", "YiACAD ECAD/MCAD sync summary"),
artifact_entry(context_path, "evidence", "YiACAD context record"),
artifact_entry(run_dir / "result.json", "evidence", "YiACAD raw payload"),
]
for name, label, kind in (
("kicad_step.stdout.txt", "KiCad STEP stdout", "log"),
("kicad_step.stderr.txt", "KiCad STEP stderr", "log"),
("freecad_step.stdout.txt", "FreeCAD STEP stdout", "log"),
("freecad_step.stderr.txt", "FreeCAD STEP stderr", "log"),
):
path = run_dir / name
if path.exists():
artifacts.append(artifact_entry(path, kind, label))
for step_path in run_dir.glob("*.step"):
artifacts.append(artifact_entry(step_path, "export", f"STEP export: {step_path.name}"))
output_payload = build_uiux_output(
surface=surface,
action="sync.ecad_mcad",
execution_mode="batch",
status=status,
severity=severity,
summary="YiACAD ECAD/MCAD sync completed.",
details=f"Board={board or 'unresolved'} | FreeCAD={freecad_document or 'unresolved'} | Artifacts={run_dir}",
context_ref=context["context_ref"],
artifacts=artifacts,
next_steps=next_steps,
latency_ms=int((time.time() - started_at) * 1000),
)
output_path = write_uiux_output(run_dir / "uiux_output.json", output_payload)
sync_payload["uiux_output"] = str(output_path)
write_json(run_dir / "result.json", sync_payload)
print_contract_or_fallback(args, output_payload, run_dir / "summary.md")
return rc_final
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="YiACAD native concrete CAD utilities")
subparsers = parser.add_subparsers(dest="command", required=True)
common = argparse.ArgumentParser(add_help=False)
common.add_argument(
"--json-output",
action="store_true",
help="Emit the normalized YiACAD UI/UX output JSON instead of the default path output",
)
status = subparsers.add_parser("status", parents=[common], help="Show YiACAD fusion and native surface status")
status.set_defaults(func=command_status)
erc_drc = subparsers.add_parser("kicad-erc-drc", parents=[common], help="Run KiCad ERC and DRC reports")
erc_drc.add_argument("--source-path", default="", help="Any project path to infer KiCad files from")
erc_drc.add_argument("--board", default="", help="Path to .kicad_pcb")
erc_drc.add_argument("--schematic", default="", help="Path to .kicad_sch")
erc_drc.set_defaults(func=command_kicad_erc_drc)
bom = subparsers.add_parser("bom-review", parents=[common], help="Export and summarize a KiCad BOM")
bom.add_argument("--source-path", default="", help="Any project path to infer schematic from")
bom.add_argument("--schematic", default="", help="Path to .kicad_sch")
bom.set_defaults(func=command_bom_review)
sync = subparsers.add_parser("ecad-mcad-sync", parents=[common], help="Export KiCad and FreeCAD STEP artifacts for sync")
sync.add_argument("--source-path", default="", help="Any project path to infer paired files from")
sync.add_argument("--board", default="", help="Path to .kicad_pcb")
sync.add_argument("--schematic", default="", help="Path to .kicad_sch")
sync.add_argument("--freecad-document", default="", help="Path to .FCStd")
sync.set_defaults(func=command_ecad_mcad_sync)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
MATRIX_DOC="${ROOT_DIR}/docs/AGENT_SPEC_MODULE_MATRIX_2026-03-20.md"
TASKS_DOC="${ROOT_DIR}/specs/04_tasks.md"
PLAN_DOC="${ROOT_DIR}/docs/plans/12_plan_gestion_des_agents.md"
INSERTION_DOC="${ROOT_DIR}/docs/YIACAD_NATIVE_UI_INSERTION_POINTS_2026-03-20.md"
LOG_DIR="${ROOT_DIR}/artifacts/cockpit"
mkdir -p "${LOG_DIR}"
ACTION="summary"
JSON=0
RETENTION_DAYS=7
LOG_FILE="${LOG_DIR}/agent_matrix_tui_$(date '+%Y%m%d_%H%M%S').log"
log_line() {
local level="$1"
shift
local msg="${level} $(date '+%Y-%m-%d %H:%M:%S %z') ${*}"
printf '%s\n' "${msg}" | tee -a "${LOG_FILE}"
}
usage() {
cat <<'EOF'
Usage: bash tools/cockpit/agent_matrix_tui.sh [--action summary|owners|open-tasks|insertion-points|logs-list|logs-latest|clean-logs] [--json] [--days N]
EOF
}
count_lines() {
local pattern="$1"
local file="$2"
rg -c "${pattern}" "${file}" 2>/dev/null || printf '0\n'
}
emit_summary_json() {
local specs_count="$1"
local kl_modules_count="$2"
local mascarade_count="$3"
local crazy_count="$4"
local open_tasks="$5"
local native_forks_count="$6"
printf '{\n'
printf ' "generated_at": "%s",\n' "$(date '+%Y-%m-%d %H:%M:%S %z')"
printf ' "matrix_doc": "%s",\n' "${MATRIX_DOC}"
printf ' "spec_rows": %s,\n' "${specs_count}"
printf ' "kill_life_module_rows": %s,\n' "${kl_modules_count}"
printf ' "mascarade_rows": %s,\n' "${mascarade_count}"
printf ' "crazy_life_rows": %s,\n' "${crazy_count}"
printf ' "native_fork_rows": %s,\n' "${native_forks_count}"
printf ' "open_tasks": %s,\n' "${open_tasks}"
printf ' "log_file": "%s"\n' "${LOG_FILE}"
printf '}\n'
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--json)
JSON=1
shift
;;
--days)
RETENTION_DAYS="${2:-7}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
case "${ACTION}" in
summary)
specs_count="$(count_lines '^\| `specs/' "${MATRIX_DOC}")"
kl_modules_count="$(count_lines '^\| `tools/|^\| `workflows/|^\| `\.github/workflows/|^\| `hardware/|^\| `firmware/|^\| `compliance/|^\| `openclaw/|^\| `agents/|^\| `docs/' "${MATRIX_DOC}")"
mascarade_count="$(count_lines '^\| `WS[0-9]' "${MATRIX_DOC}")"
crazy_count="$(count_lines '^\| `WS-[0-9]' "${MATRIX_DOC}")"
native_forks_count="$(count_lines '^\| `\.runtime-home/cad-ai-native-forks/' "${MATRIX_DOC}")"
open_tasks="$(count_lines '^- \[ \]' "${TASKS_DOC}")"
log_line "INFO" "spec_rows=${specs_count} kill_life_module_rows=${kl_modules_count} mascarade_rows=${mascarade_count} crazy_life_rows=${crazy_count} native_fork_rows=${native_forks_count} open_tasks=${open_tasks}"
if [[ "${JSON}" -eq 1 ]]; then
emit_summary_json "${specs_count}" "${kl_modules_count}" "${mascarade_count}" "${crazy_count}" "${open_tasks}" "${native_forks_count}"
else
printf 'Matrix: %s\n' "${MATRIX_DOC}"
printf 'Spec rows: %s\n' "${specs_count}"
printf 'Kill_LIFE module rows: %s\n' "${kl_modules_count}"
printf 'mascarade rows: %s\n' "${mascarade_count}"
printf 'crazy_life rows: %s\n' "${crazy_count}"
printf 'Native fork rows: %s\n' "${native_forks_count}"
printf 'Open tasks: %s\n' "${open_tasks}"
printf 'Log: %s\n' "${LOG_FILE}"
fi
;;
owners)
log_line "INFO" "Printing owner registry"
sed -n '1,260p' "${MATRIX_DOC}"
;;
open-tasks)
log_line "INFO" "Printing open tasks"
rg '^- \[ \]' "${TASKS_DOC}" || true
;;
insertion-points)
log_line "INFO" "Printing YiACAD native insertion points"
sed -n '1,260p' "${INSERTION_DOC}"
;;
logs-list)
log_line "INFO" "Listing agent_matrix_tui logs"
find "${LOG_DIR}" -name 'agent_matrix_tui_*.log' -type f | sort
;;
logs-latest)
log_line "INFO" "Printing latest agent_matrix_tui log"
latest_log="$(find "${LOG_DIR}" -name 'agent_matrix_tui_*.log' -type f | sort | tail -n 1)"
if [[ -n "${latest_log}" ]]; then
cat "${latest_log}"
fi
;;
clean-logs)
log_line "INFO" "Cleaning agent_matrix_tui logs older than ${RETENTION_DAYS} days"
find "${LOG_DIR}" -name 'agent_matrix_tui_*.log' -type f -mtime +"${RETENTION_DAYS}" -delete
;;
*)
printf 'Unknown action: %s\n' "${ACTION}" >&2
usage >&2
exit 2
;;
esac
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env bash
set -euo pipefail
# ─────────────────────────────────────────────────────────────────
# Aperant ↔ Kill_LIFE Bridge
# Provides cockpit entry points for the Aperant multi-agent
# autonomous coding framework (git submodule at tools/aperant).
# ─────────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
APERANT_DIR="${ROOT_DIR}/tools/aperant"
WEB_DIR="${ROOT_DIR}/web/aperant"
TOWER_HOST="clems@192.168.0.120"
# ── helpers ──────────────────────────────────────────────────────
_ensure_submodule() {
if [ ! -f "${APERANT_DIR}/package.json" ]; then
echo "⚠ Aperant submodule not initialised running git submodule update …"
git -C "${ROOT_DIR}" submodule update --init --recursive -- tools/aperant
fi
}
_require_node() {
if ! command -v node &>/dev/null; then
echo "✗ node not found. Aperant requires Node >= 24." >&2
exit 1
fi
}
_aperant_version() {
node -e "console.log(require('${APERANT_DIR}/package.json').version)" 2>/dev/null || echo "unknown"
}
# ── commands ─────────────────────────────────────────────────────
cmd_status() {
_ensure_submodule
local ver
ver=$(_aperant_version)
local branch
branch=$(git -C "${APERANT_DIR}" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "detached")
local sha
sha=$(git -C "${APERANT_DIR}" rev-parse --short HEAD 2>/dev/null || echo "?")
echo "┌──────────────────────────────────────┐"
echo "│ Aperant v${ver}"
echo "│ branch : ${branch}"
echo "│ commit : ${sha}"
echo "│ path : tools/aperant │"
echo "└──────────────────────────────────────┘"
# Check if desktop app deps are installed
if [ -d "${APERANT_DIR}/apps/desktop/node_modules" ]; then
echo " deps : installed ✓"
else
echo " deps : not installed (run: make aperant-install)"
fi
}
cmd_install() {
_ensure_submodule
_require_node
echo "── Installing Aperant dependencies …"
(cd "${APERANT_DIR}" && npm run install:all)
echo "✓ Aperant dependencies installed."
}
cmd_dev() {
_ensure_submodule
_require_node
echo "── Launching Aperant in dev mode …"
(cd "${APERANT_DIR}" && npm run dev)
}
cmd_start() {
_ensure_submodule
_require_node
echo "── Building & launching Aperant …"
(cd "${APERANT_DIR}" && npm start)
}
cmd_build() {
_ensure_submodule
_require_node
echo "── Building Aperant …"
(cd "${APERANT_DIR}" && npm run build)
}
cmd_test() {
_ensure_submodule
_require_node
echo "── Running Aperant tests …"
(cd "${APERANT_DIR}" && npm test)
}
cmd_update() {
_ensure_submodule
echo "── Pulling latest Aperant (develop) …"
git -C "${APERANT_DIR}" fetch origin
git -C "${APERANT_DIR}" checkout develop
git -C "${APERANT_DIR}" pull --ff-only origin develop
echo "✓ Aperant updated to $(git -C "${APERANT_DIR}" rev-parse --short HEAD)"
}
# ── web commands ─────────────────────────────────────────────────
cmd_web_install() {
_ensure_submodule
_require_node
echo "── Installing Aperant Web dependencies …"
(cd "${WEB_DIR}" && npm install)
echo "✓ Web dependencies installed."
}
cmd_web_dev() {
_ensure_submodule
_require_node
echo "── Launching Aperant Web (UI :5180 + API :5181) …"
(cd "${WEB_DIR}" && npm run dev)
}
cmd_web_build() {
_ensure_submodule
_require_node
echo "── Building Aperant Web …"
(cd "${WEB_DIR}" && npm run build)
}
cmd_deploy() {
_ensure_submodule
_require_node
echo "── Deploying Aperant Web → aperant.saillant.cc (tower) …"
bash "${WEB_DIR}/deploy.sh"
}
cmd_tower_status() {
echo "── Aperant Web on tower …"
ssh "${TOWER_HOST}" "pm2 describe aperant-web 2>/dev/null || echo 'not running'"
}
cmd_tower_logs() {
ssh "${TOWER_HOST}" "pm2 logs aperant-web --lines ${2:-50}"
}
# ── main ─────────────────────────────────────────────────────────
usage() {
cat <<'EOF'
Usage: bash tools/cockpit/aperant_bridge.sh <command>
Submodule (Electron desktop):
status Show Aperant version, branch, and dep state
install Install Aperant desktop app dependencies
dev Launch Aperant in dev mode (HMR)
start Build & launch Aperant desktop app
build Build Aperant (no launch)
test Run Aperant test suite
update Pull latest from Aperant develop branch
Web (aperant.saillant.cc):
web-install Install web app dependencies
web-dev Launch web UI (:5180) + API (:5181) locally
web-build Build web app for production
deploy Build + deploy to tower (clems@192.168.0.120)
tower-status Check PM2 status on tower
tower-logs Tail PM2 logs on tower
Aperant is an autonomous multi-agent AI coding framework that
orchestrates Claude Code agents for planning, building, and QA.
EOF
}
case "${1:-}" in
status) cmd_status ;;
install) cmd_install ;;
dev) cmd_dev ;;
start) cmd_start ;;
build) cmd_build ;;
test) cmd_test ;;
update) cmd_update ;;
web-install) cmd_web_install ;;
web-dev) cmd_web_dev ;;
web-build) cmd_web_build ;;
deploy) cmd_deploy ;;
tower-status) cmd_tower_status ;;
tower-logs) cmd_tower_logs ;;
-h|--help|"") usage ;;
*)
echo "Unknown command: $1" >&2
usage >&2
exit 1
;;
esac
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
set -euo pipefail
# artifact_wms_index_tui.sh
# Index latest artifacts by lot, layer, and consumer.
# Contract: cockpit-v1
# Date: 2026-03-22
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ARTIFACT_ROOT="${ARTIFACT_ROOT:-${ROOT_DIR}/artifacts}"
RULES_FILE="${RULES_FILE:-${ROOT_DIR}/specs/contracts/artifact_wms_index_rules.json}"
ACTION="summary"
JSON_MODE=0
usage() {
cat <<'EOF'
Usage: artifact_wms_index_tui.sh [--action summary|entries|unknown] [--json]
Actions:
summary Show aggregate WMS artifact index summary
entries Show all indexed latest artifacts
unknown Show only artifacts that did not match a rule
Options:
--json Emit cockpit-v1 JSON
--help Show this help
EOF
}
run_view() {
python3 - "$ARTIFACT_ROOT" "$RULES_FILE" "$ACTION" "$JSON_MODE" <<'PY'
import fnmatch
import json
import sys
from pathlib import Path
artifact_root = Path(sys.argv[1])
rules_file = Path(sys.argv[2])
action = sys.argv[3]
json_mode = sys.argv[4] == "1"
rules = json.loads(rules_file.read_text(encoding="utf-8"))
patterns = rules["scan"]["latest_patterns"]
rule_entries = rules["rules"]
defaults = rules["defaults"]
latest_files = []
for pattern in patterns:
latest_files.extend(artifact_root.rglob(pattern))
seen = set()
entries = []
def match_rule(relpath: str):
for rule in rule_entries:
if rule["match"] in relpath:
return rule
return defaults
for path in sorted(latest_files):
rel = path.relative_to(artifact_root).as_posix()
if rel in seen:
continue
seen.add(rel)
rule = match_rule(rel)
entry = {
"artifact": rel,
"absolute_path": str(path),
"consumer_layer": rule["consumer_layer"],
"owner_agent": rule["owner_agent"],
"lot_refs": rule["lot_refs"],
"purpose": rule["purpose"],
"size_bytes": path.stat().st_size if path.exists() else 0,
"status": "known" if rule is not defaults else "unknown",
}
entries.append(entry)
unknown = [entry for entry in entries if entry["status"] == "unknown"]
payload = {
"contract_version": "cockpit-v1",
"component": "artifact-wms-index",
"action": action,
"status": "ok",
"artifact_root": str(artifact_root),
"rules_file": str(rules_file),
"entries_total": len(entries),
"unknown_total": len(unknown),
}
if action == "summary":
counts = {}
for entry in entries:
counts[entry["consumer_layer"]] = counts.get(entry["consumer_layer"], 0) + 1
payload["counts_by_consumer_layer"] = counts
payload["sample_latest"] = entries[:10]
elif action == "entries":
payload["entries"] = entries
elif action == "unknown":
payload["entries"] = unknown
else:
raise SystemExit(f"unknown action: {action}")
if json_mode:
print(json.dumps(payload, indent=2))
raise SystemExit(0)
if action == "summary":
print("Artifact WMS Index")
print(f"artifact root: {artifact_root}")
print(f"rules file: {rules_file}")
print(f"entries total: {len(entries)}")
print(f"unknown total: {len(unknown)}")
for layer, count in sorted(payload["counts_by_consumer_layer"].items()):
print(f"- {layer}: {count}")
elif action in {"entries", "unknown"}:
print("Artifacts")
for entry in payload["entries"]:
print(
f"- {entry['artifact']} | consumer={entry['consumer_layer']} | "
f"owner={entry['owner_agent']} | lots={','.join(entry['lot_refs']) or '-'} | "
f"status={entry['status']}"
)
PY
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--json)
JSON_MODE=1
shift
;;
--help|-h)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
case "$ACTION" in
summary|entries|unknown)
run_view
;;
*)
printf 'Unknown action: %s\n' "$ACTION" >&2
usage >&2
exit 2
;;
esac
+440
View File
@@ -0,0 +1,440 @@
#!/usr/bin/env bash
set -euo pipefail
# ============================================================================
# dataset_audit_tui.sh — Audit and preflight for Mistral fine-tune datasets
# Contract: cockpit-v1
# Lots: 23 / 24 — T-MA-015, T-MS-002, T-MS-003
# Date: 2026-03-22
# ============================================================================
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
MASCARADE_ROOT="${MASCARADE_ROOT:-/Users/electron/Documents/Projets/mascarade}"
FINETUNE_DIR="${FINETUNE_DIR:-${MASCARADE_ROOT}/finetune}"
DATASETS_DIR="${DATASETS_DIR:-${FINETUNE_DIR}/datasets}"
FORBIDDEN_MASCARADE_ROOT="${FORBIDDEN_MASCARADE_ROOT:-/Users/electron/Documents/Lelectron_rare/Github_Repos/Perso/mascarade-main}"
WORKSPACE_GUARD="${ROOT_DIR}/tools/cockpit/mistral_workspace_guard.sh"
DATASET_VM_HOST="${DATASET_VM_HOST:-photon-docker}"
ACTION="audit"
JSON_MODE=0
DATASETS=(
"kicad:build_kicad_dataset.py:KiCad PCB/Schematic"
"spice:build_spice_dataset.py:SPICE Simulation"
"freecad:build_freecad_dataset.py:FreeCAD 3D/Mecanique"
"stm32:build_stm32_dataset.py:STM32 HAL/Firmware"
"embedded:build_embedded_dataset.py:Embedded C/C++"
"iot:build_iot_dataset.py:IoT MQTT/LoRa/Zigbee"
"emc:build_emc_dataset.py:EMC/CEM Compatibilite"
"dsp:build_dsp_dataset.py:DSP Traitement Signal"
"power:build_power_dataset.py:Power Electronics"
"platformio:build_platformio_dataset.py:PlatformIO CI/CD"
)
usage() {
cat <<'EOF'
Usage: dataset_audit_tui.sh [--action audit|preflight|paths] [--json]
Actions:
audit Static analysis of dataset builders and generated files
preflight Report readiness for T-MS-002/003 and downstream fine-tune lots
paths Print canonical paths
Options:
--json Emit cockpit-v1 JSON only
--help Show this help
Env vars:
MASCARADE_ROOT Active Mascarade workspace (default: /Users/electron/Documents/Projets/mascarade)
FINETUNE_DIR Fine-tune root override
DATASETS_DIR Dataset builders/output root override
DATASET_VM_HOST VM host label for upload/fine-tune lots (default: photon-docker)
EOF
}
json_escape() {
python3 - "$1" <<'PY'
import json
import sys
print(json.dumps(sys.argv[1]))
PY
}
bool_json() {
if [[ "$1" == "1" ]]; then
printf 'true'
else
printf 'false'
fi
}
builder_path() {
local script="$1"
if [[ -f "${DATASETS_DIR}/${script}" ]]; then
printf '%s\n' "${DATASETS_DIR}/${script}"
return 0
fi
if [[ -f "${FINETUNE_DIR}/${script}" ]]; then
printf '%s\n' "${FINETUNE_DIR}/${script}"
return 0
fi
return 1
}
count_found_builders() {
local found=0
local entry script
for entry in "${DATASETS[@]}"; do
IFS=':' read -r _ script _ <<< "${entry}"
if builder_path "${script}" >/dev/null; then
((found+=1))
fi
done
printf '%s\n' "${found}"
}
count_generated_files() {
local found=0
local dir
local dirs=(
"${DATASETS_DIR}"
"${FINETUNE_DIR}/datasets"
"${FINETUNE_DIR}"
)
for dir in "${dirs[@]}"; do
if [[ -d "${dir}" ]]; then
while IFS= read -r _; do
((found+=1))
done < <(find "${dir}" -maxdepth 1 -type f \( -name '*.jsonl' -o -name '*.json' -o -name '*.csv' -o -name '*.parquet' \) 2>/dev/null)
fi
done
printf '%s\n' "${found}"
}
print_header() {
echo -e "${BOLD}${CYAN}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${CYAN}║ Dataset Fine-tune Audit / Preflight ║${NC}"
echo -e "${BOLD}${CYAN}$(date '+%Y-%m-%d %H:%M:%S')${NC}"
echo -e "${BOLD}${CYAN}╚══════════════════════════════════════════════════╝${NC}"
echo ""
}
emit_status_json() {
local component="$1"
local action="$2"
local status="$3"
local reason="$4"
local builders_found="$5"
local generated_files="$6"
local upload_ready="$7"
local finetune_ready="$8"
local workspace_guard_available=0
[[ -x "${WORKSPACE_GUARD}" ]] && workspace_guard_available=1
cat <<EOF
{
"contract_version": "cockpit-v1",
"component": "${component}",
"action": "${action}",
"status": "${status}",
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"reason": $(json_escape "${reason}"),
"tracking_root": $(json_escape "${ROOT_DIR}"),
"mascarade_root": $(json_escape "${MASCARADE_ROOT}"),
"finetune_dir": $(json_escape "${FINETUNE_DIR}"),
"datasets_dir": $(json_escape "${DATASETS_DIR}"),
"forbidden_mascarade_root": $(json_escape "${FORBIDDEN_MASCARADE_ROOT}"),
"workspace_guard": $(json_escape "${WORKSPACE_GUARD}"),
"workspace_guard_available": $(bool_json "${workspace_guard_available}"),
"mascarade_root_exists": $(bool_json "$([[ -d "${MASCARADE_ROOT}" ]] && echo 1 || echo 0)"),
"finetune_dir_exists": $(bool_json "$([[ -d "${FINETUNE_DIR}" ]] && echo 1 || echo 0)"),
"datasets_dir_exists": $(bool_json "$([[ -d "${DATASETS_DIR}" ]] && echo 1 || echo 0)"),
"forbidden_copy_present": $(bool_json "$([[ -e "${FORBIDDEN_MASCARADE_ROOT}" ]] && echo 1 || echo 0)"),
"builders_total": ${#DATASETS[@]},
"builders_found": ${builders_found},
"generated_files": ${generated_files},
"dataset_vm_host": $(json_escape "${DATASET_VM_HOST}"),
"dataset_vm_required": true,
"dataset_vm_access_checked": false,
"upload_ready": $(bool_json "${upload_ready}"),
"finetune_ready": $(bool_json "${finetune_ready}"),
"blocked_lots": ["T-MS-002","T-MS-003","T-MA-016","T-MA-017","T-MA-021"],
"next_steps": [
"Use the active Mascarade workspace only: /Users/electron/Documents/Projets/mascarade",
"Prepare merged and validated datasets before T-MS-002/T-MS-003",
"Run upload and fine-tune lots on the dataset VM host after local preflight is green"
]
}
EOF
}
action_paths() {
if [[ "${JSON_MODE}" -eq 1 ]]; then
emit_status_json "dataset-audit" "paths" "ok" "canonical paths" 0 0 0 0
else
printf '%s\n' "${ROOT_DIR}"
printf '%s\n' "${MASCARADE_ROOT}"
printf '%s\n' "${FINETUNE_DIR}"
printf '%s\n' "${DATASETS_DIR}"
printf '%s\n' "${FORBIDDEN_MASCARADE_ROOT}"
fi
}
action_preflight() {
local builders_found generated_files status reason upload_ready=0 finetune_ready=0
builders_found="$(count_found_builders)"
generated_files="$(count_generated_files)"
status="ready"
reason="dataset builders and outputs available for the next VM-bound lots"
if [[ ! -d "${MASCARADE_ROOT}" || ! -d "${FINETUNE_DIR}" || ! -d "${DATASETS_DIR}" ]]; then
status="blocked"
reason="active Mascarade fine-tune paths missing"
elif [[ "${builders_found}" -lt "${#DATASETS[@]}" ]]; then
status="degraded"
reason="dataset builder coverage incomplete"
elif [[ "${generated_files}" -eq 0 ]]; then
status="degraded"
reason="no generated dataset files found yet"
fi
if [[ "${status}" == "ready" ]]; then
upload_ready=1
finetune_ready=1
fi
if [[ "${JSON_MODE}" -eq 1 ]]; then
emit_status_json "dataset-audit" "preflight" "${status}" "${reason}" "${builders_found}" "${generated_files}" "${upload_ready}" "${finetune_ready}"
return
fi
print_header
echo -e "${BOLD}[Preflight]${NC}"
echo -e " Status: ${status}"
echo -e " Reason: ${reason}"
echo -e " Active Mascarade root: ${MASCARADE_ROOT}"
echo -e " Fine-tune dir: ${FINETUNE_DIR}"
echo -e " Datasets dir: ${DATASETS_DIR}"
echo -e " Builders found: ${builders_found}/${#DATASETS[@]}"
echo -e " Generated files: ${generated_files}"
echo -e " VM host for upload/fine-tune: ${DATASET_VM_HOST}"
echo -e " Lots gated by VM: T-MS-002, T-MS-003, T-MA-016, T-MA-017, T-MA-021"
}
audit_builder() {
local name="$1"
local script="$2"
local desc="$3"
local script_path=""
if script_path="$(builder_path "${script}" 2>/dev/null)"; then
:
else
echo -e " ${RED}x${NC} ${BOLD}${name}${NC} (${desc}): ${RED}Script not found${NC}"
echo "NOT_FOUND"
return
fi
local lines score has_output_format has_validation has_dedup has_balance_check issues
lines="$(wc -l < "${script_path}" 2>/dev/null || echo 0)"
score=0
has_output_format=0
has_validation=0
has_dedup=0
has_balance_check=0
issues=""
grep -Eq "jsonl|json\.dumps|to_json|ChatML|messages" "${script_path}" 2>/dev/null && has_output_format=1
grep -Eq "validate|assert|check|quality|filter" "${script_path}" 2>/dev/null && has_validation=1
grep -Eq "deduplicate|dedup|drop_duplicates|set\(\)|unique" "${script_path}" 2>/dev/null && has_dedup=1
grep -Eq "balance|distribution|count.*category|value_counts|Counter" "${script_path}" 2>/dev/null && has_balance_check=1
[[ "${lines}" -gt 50 ]] && ((score+=1))
[[ "${lines}" -gt 200 ]] && ((score+=1))
[[ "${has_output_format}" -eq 1 ]] && ((score+=2))
[[ "${has_validation}" -eq 1 ]] && ((score+=2))
[[ "${has_dedup}" -eq 1 ]] && ((score+=1))
[[ "${has_balance_check}" -eq 1 ]] && ((score+=1))
[[ "${lines}" -lt 20 ]] && issues="${issues}skeleton-only "
[[ "${has_output_format}" -eq 0 ]] && issues="${issues}no-output-format "
[[ "${has_validation}" -eq 0 ]] && issues="${issues}no-validation "
[[ "${has_dedup}" -eq 0 ]] && issues="${issues}no-dedup "
local color="${RED}"
local status_text="POOR"
if [[ "${score}" -ge 6 ]]; then
color="${GREEN}"
status_text="GOOD"
elif [[ "${score}" -ge 3 ]]; then
color="${YELLOW}"
status_text="FAIR"
fi
echo -e " ${color}o${NC} ${BOLD}${name}${NC} (${desc})"
echo -e " Path: ${script_path}"
echo -e " Lines: ${lines} | Score: ${score}/8 | Status: ${color}${status_text}${NC}"
if [[ -n "${issues}" ]]; then
echo -e " Issues: ${YELLOW}${issues}${NC}"
fi
echo "${score}"
}
check_generated_datasets() {
echo -e "\n${BOLD}[Generated Dataset Files]${NC}"
local dirs=(
"${DATASETS_DIR}"
"${FINETUNE_DIR}/datasets"
"${FINETUNE_DIR}"
)
local dir
local found=0
for dir in "${dirs[@]}"; do
if [[ -d "${dir}" ]]; then
while IFS= read -r f; do
local size lines
size="$(du -h "${f}" 2>/dev/null | cut -f1)"
lines="$(wc -l < "${f}" 2>/dev/null || echo '?')"
echo -e " ${GREEN}o${NC} $(basename "${f}"): ${size} (${lines} lines)"
((found+=1))
done < <(find "${dir}" -maxdepth 1 -type f \( -name '*.jsonl' -o -name '*.json' -o -name '*.csv' -o -name '*.parquet' \) 2>/dev/null)
fi
done
if [[ "${found}" -eq 0 ]]; then
echo -e " ${YELLOW}o${NC} No generated dataset files found"
fi
echo -e " Total: ${found} dataset files"
}
action_audit() {
local builders_found generated_files
builders_found="$(count_found_builders)"
generated_files="$(count_generated_files)"
if [[ "${JSON_MODE}" -eq 1 ]]; then
local status="pass"
local reason="dataset audit completed"
local upload_ready=0
local finetune_ready=0
if [[ ! -d "${MASCARADE_ROOT}" || ! -d "${FINETUNE_DIR}" || ! -d "${DATASETS_DIR}" ]]; then
status="blocked"
reason="active Mascarade fine-tune paths missing"
elif [[ "${builders_found}" -lt "${#DATASETS[@]}" || "${generated_files}" -eq 0 ]]; then
status="needs-work"
reason="dataset builders or outputs incomplete"
else
upload_ready=1
finetune_ready=1
fi
emit_status_json "dataset-audit" "audit" "${status}" "${reason}" "${builders_found}" "${generated_files}" "${upload_ready}" "${finetune_ready}"
return
fi
print_header
echo -e "${BOLD}[Workspace Policy]${NC}"
echo -e " Tracking root: ${ROOT_DIR}"
echo -e " Active Mascarade root: ${MASCARADE_ROOT}"
echo -e " Forbidden copy: ${FORBIDDEN_MASCARADE_ROOT}"
echo -e " VM host for upload/fine-tune: ${DATASET_VM_HOST}"
echo ""
echo -e "${BOLD}[Builder Scripts Analysis]${NC}\n"
local total=0 good=0 fair=0 poor=0 missing=0 total_score=0
local entry result score
for entry in "${DATASETS[@]}"; do
IFS=':' read -r name script desc <<< "${entry}"
((total+=1))
result="$(audit_builder "${name}" "${script}" "${desc}" 2>/dev/null)"
score="$(echo "${result}" | tail -n 1)"
case "${score}" in
NOT_FOUND) ((missing+=1)) ;;
[6-8]) ((good+=1)); total_score=$((total_score + score)) ;;
[3-5]) ((fair+=1)); total_score=$((total_score + score)) ;;
*) ((poor+=1)); total_score=$((total_score + ${score:-0})) ;;
esac
echo "${result}" | head -n -1
echo ""
done
check_generated_datasets
echo ""
local avg_score=0
[[ "${total}" -gt 0 ]] && avg_score=$((total_score / total))
echo -e "${BOLD}[Audit Summary]${NC}"
echo -e " Builders: ${total} total"
echo -e " ${GREEN}Good${NC}: ${good} | ${YELLOW}Fair${NC}: ${fair} | ${RED}Poor${NC}: ${poor} | Missing: ${missing}"
echo -e " Average score: ${avg_score}/8"
echo -e " Builders found on disk: ${builders_found}/${#DATASETS[@]}"
echo -e " Generated dataset files: ${generated_files}"
echo ""
echo -e "${BOLD}[Lot Readiness]${NC}"
echo -e " Local audit lot: T-MA-015"
echo -e " VM-gated upload lots: T-MS-002, T-MS-003"
echo -e " VM-gated fine-tune lots: T-MA-016, T-MA-017"
echo -e " Downstream benchmark lot: T-MA-021"
if [[ "${missing}" -gt 0 || "${generated_files}" -eq 0 ]]; then
echo -e " ${YELLOW}->${NC} Local preflight is not ready for VM upload/fine-tune"
else
echo -e " ${GREEN}o${NC} Local preflight is ready; next gate remains VM execution"
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--json)
JSON_MODE=1
shift
;;
--help|-h)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
case "${ACTION}" in
audit)
action_audit
;;
preflight)
action_preflight
;;
paths)
action_paths
;;
*)
printf 'Unknown action: %s\n' "${ACTION}" >&2
usage >&2
exit 2
;;
esac
+471
View File
@@ -0,0 +1,471 @@
#!/usr/bin/env bash
set -euo pipefail
# ──────────────────────────────────────────────────────────────────────────
# e2e_agents_test.sh — Tests E2E Mistral Agents (Lot 23 T-MA-025)
#
# Scénarios:
# 1. Sentinelle health-check → diagnostic
# 2. Devstral code review → fix suggestion
# 3. Handoff Sentinelle → Devstral (détection anomalie → correction auto)
# 4. Tower email generation → validation template
# 5. Forge dataset validation → score
#
# Usage:
# bash e2e_agents_test.sh --action all
# bash e2e_agents_test.sh --action sentinelle
# bash e2e_agents_test.sh --action handoff
# bash e2e_agents_test.sh --json
#
# Contract: cockpit-v1
# Owner: QA + PM-Mesh
# Date: 2026-03-21
# ──────────────────────────────────────────────────────────────────────────
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ARTIFACTS_DIR="${ROOT_DIR}/artifacts/e2e_agents_test"
mkdir -p "${ARTIFACTS_DIR}"
# shellcheck source=/dev/null
source "${ROOT_DIR}/tools/cockpit/load_mistral_governance_env.sh"
STAMP="$(date +%Y%m%d_%H%M%S)"
LOG_FILE="${ARTIFACTS_DIR}/e2e_${STAMP}.log"
ACTION=""
JSON_MODE=0
VERBOSE=0
TIMEOUT=30
MISTRAL_BASE="${MISTRAL_BASE:-https://api.mistral.ai/v1}"
API_KEY="${MISTRAL_GOVERNANCE_API_KEY:-${MISTRAL_API_KEY:-${MISTRAL_AGENTS_API_KEY:-}}}"
# Agent IDs (from lot 23)
SENTINELLE_ID="${MISTRAL_AGENT_SENTINELLE_ID:-ag_019d124c302375a8bf06f9ff8a99fb5f}"
TOWER_ID="${MISTRAL_AGENT_TOWER_ID:-ag_019d124e760877359ad3ff5031179ebc}"
FORGE_ID="${MISTRAL_AGENT_FORGE_ID:-ag_019d1251023f73258b80ac73f90458f6}"
DEVSTRAL_ID="${MISTRAL_AGENT_DEVSTRAL_ID:-ag_019d125348eb77e880df33acbd395efa}"
# Couleurs
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
PASS=0
FAIL=0
SKIP=0
usage() {
cat <<'EOF'
Usage: e2e_agents_test.sh --action <all|sentinelle|tower|forge|devstral|handoff> [options]
Options:
--action <name> Test suite to run (default: all)
--api-mode <mode> API mode: beta (conversations) or deprecated (agents/completions)
--json Emit JSON report
--timeout <sec> API timeout in seconds (default: 30)
--verbose Show full API responses
--help Show this help
EOF
}
log_info() { printf "${CYAN}[INFO]${NC} %s\n" "$*"; }
log_warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$*"; }
log_pass() { printf "${GREEN}[PASS]${NC} %s\n" "$*"; ((PASS++)); }
log_fail() { printf "${RED}[FAIL]${NC} %s\n" "$*"; ((FAIL++)); }
log_skip() { printf "${YELLOW}[SKIP]${NC} %s\n" "$*"; ((SKIP++)); }
check_api_key() {
if [[ -z "${API_KEY}" ]]; then
log_fail "MISTRAL_API_KEY ou MISTRAL_AGENTS_API_KEY non défini"
return 1
fi
return 0
}
# API Mode: "beta" (conversations/completions) or "deprecated" (agents/completions)
API_MODE="${API_MODE:-beta}"
# ── Agent Chat Helper ─────────────────────────────────────────────────────
build_beta_payload() {
local agent_id="$1"
local message="$2"
python3 - "$agent_id" "$message" <<'PY'
import json
import sys
agent_id = sys.argv[1]
message = sys.argv[2]
print(json.dumps({
"agent_id": agent_id,
"inputs": [{"role": "user", "content": message}],
}))
PY
}
build_deprecated_payload() {
local message="$1"
python3 - "$message" <<'PY'
import json
import sys
message = sys.argv[1]
print(json.dumps({
"messages": [{"role": "user", "content": message}],
}))
PY
}
_call_api() {
local endpoint="$1"
local payload="$2"
local label="${3:-agent}"
local response
response=$(curl -s -w "\n%{http_code}" \
--max-time "${TIMEOUT}" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-d "${payload}" \
"${MISTRAL_BASE}/${endpoint}" 2>/dev/null) || true
local http_code
http_code=$(echo "${response}" | tail -1)
local body
body=$(echo "${response}" | sed '$d')
if [[ "${VERBOSE}" -eq 1 ]]; then
printf "[%s] HTTP %s -> %s\n%s\n" "${label}" "${http_code}" "${endpoint}" "${body}" >&2
fi
if [[ "${http_code}" == "200" ]]; then
echo "${body}"
return 0
else
echo "HTTP_ERROR:${http_code}:${body}"
return 1
fi
}
call_agent() {
local agent_id="$1"
local message="$2"
local label="${3:-agent}"
local beta_payload
local deprecated_payload
local result=""
beta_payload="$(build_beta_payload "${agent_id}" "${message}")"
deprecated_payload="$(build_deprecated_payload "${message}")"
# Try Beta Conversations API first
if [[ "${API_MODE}" == "beta" ]]; then
if result=$(_call_api "conversations" "${beta_payload}" "${label}" 2>/dev/null); then
printf "${CYAN}[INFO]${NC} %s\n" " -> Beta API (/conversations)" >&2
echo "${result}"
return 0
fi
printf "${YELLOW}[WARN]${NC} %s\n" " -> Beta API failed, fallback deprecated" >&2
fi
# Deprecated API (fallback or explicit mode)
if result=$(_call_api "agents/${agent_id}/completions" "${deprecated_payload}" "${label}" 2>/dev/null); then
printf "${CYAN}[INFO]${NC} %s\n" " -> Deprecated API (/agents/${agent_id}/completions)" >&2
echo "${result}"
return 0
fi
echo "${result}"
return 1
}
extract_content() {
local response="$1"
python3 - "$response" <<'PY' 2>/dev/null || echo ""
import json
import sys
raw = sys.argv[1]
def render_content(content):
if isinstance(content, str):
return content
if isinstance(content, list):
chunks = []
for item in content:
if isinstance(item, str):
if item:
chunks.append(item)
continue
if not isinstance(item, dict):
continue
text = item.get("text")
if isinstance(text, str) and text:
chunks.append(text)
return "\n".join(chunks)
return ""
try:
data = json.loads(raw)
outputs = data.get("outputs", [])
for out in outputs:
if out.get("role") == "assistant":
print(render_content(out.get("content", "")))
raise SystemExit(0)
choices = data.get("choices", [])
if choices:
print(render_content(choices[0].get("message", {}).get("content", "")))
else:
print("")
except Exception:
print("")
PY
}
# ── Test Suites ───────────────────────────────────────────────────────────
test_sentinelle() {
log_info "=== Test Suite: Sentinelle (Ops Monitoring) ==="
# T1: Health check prompt
log_info "T1: Sentinelle health diagnostic prompt"
local resp
if resp=$(call_agent "${SENTINELLE_ID}" \
"Effectue un diagnostic rapide de l'état du système. Vérifie: CPU, RAM, services critiques. Réponds en JSON structuré." \
"sentinelle-health" 2>/dev/null); then
local content
content=$(extract_content "${resp}")
if [[ -n "${content}" && "${#content}" -gt 20 ]]; then
log_pass "T1: Sentinelle a répondu (${#content} chars)"
else
log_fail "T1: Réponse Sentinelle vide ou trop courte"
fi
else
log_fail "T1: Appel Sentinelle échoué (${resp})"
fi
# T2: Anomaly detection prompt
log_info "T2: Sentinelle anomaly detection"
if resp=$(call_agent "${SENTINELLE_ID}" \
"Analyse ce log d'erreur et identifie la cause probable: 'ERROR 2026-03-21 05:32:12 mascarade.router: Provider timeout after 30s on mistral-large. Retry 3/3 failed. Circuit breaker OPEN.' Propose un fix." \
"sentinelle-anomaly" 2>/dev/null); then
local content
content=$(extract_content "${resp}")
if [[ -n "${content}" && "${#content}" -gt 50 ]]; then
log_pass "T2: Sentinelle anomaly analysis OK (${#content} chars)"
else
log_fail "T2: Réponse anomaly insuffisante"
fi
else
log_fail "T2: Appel Sentinelle anomaly échoué"
fi
}
test_tower() {
log_info "=== Test Suite: Tower (Commercial/CRM) ==="
# T3: Email generation
log_info "T3: Tower email generation"
local resp
if resp=$(call_agent "${TOWER_ID}" \
"Génère un email de premier contact pour un prospect ingénieur en électronique qui a téléchargé notre guide KiCad. Nom: Jean Dupont, Entreprise: TechnoBoard SAS. Ton professionnel mais chaleureux." \
"tower-email" 2>/dev/null); then
local content
content=$(extract_content "${resp}")
if [[ -n "${content}" && "${content}" == *"@"* || "${content}" == *"Dupont"* || "${content}" == *"KiCad"* ]]; then
log_pass "T3: Tower email généré avec contexte (${#content} chars)"
elif [[ -n "${content}" && "${#content}" -gt 50 ]]; then
log_pass "T3: Tower email généré (${#content} chars, contexte partiel)"
else
log_fail "T3: Tower email insuffisant"
fi
else
log_fail "T3: Appel Tower échoué"
fi
}
test_forge() {
log_info "=== Test Suite: Forge (Fine-tune Pipeline) ==="
# T4: Dataset quality evaluation
log_info "T4: Forge dataset quality assessment"
local resp
if resp=$(call_agent "${FORGE_ID}" \
"Évalue la qualité de ce dataset JSONL pour fine-tune Mistral. Stats: 5700 exemples, 12 duplicates, format ChatML, avg 450 tokens, domaine KiCad EDA. Score /10 et recommandations." \
"forge-quality" 2>/dev/null); then
local content
content=$(extract_content "${resp}")
if [[ -n "${content}" && "${#content}" -gt 30 ]]; then
log_pass "T4: Forge dataset assessment OK (${#content} chars)"
else
log_fail "T4: Forge assessment insuffisant"
fi
else
log_fail "T4: Appel Forge échoué"
fi
}
test_devstral() {
log_info "=== Test Suite: Devstral (Code/Dev Workflow) ==="
# T5: Code review
log_info "T5: Devstral code review"
local resp
if resp=$(call_agent "${DEVSTRAL_ID}" \
"Review ce code Python et suggère des améliorations:
\`\`\`python
def process_data(data):
result = []
for item in data:
if item['status'] == 'active':
result.append({'id': item['id'], 'name': item['name'].upper()})
return result
\`\`\`
Focus: performance, robustesse, type hints." \
"devstral-review" 2>/dev/null); then
local content
content=$(extract_content "${resp}")
if [[ -n "${content}" && "${#content}" -gt 50 ]]; then
log_pass "T5: Devstral code review OK (${#content} chars)"
else
log_fail "T5: Devstral review insuffisant"
fi
else
log_fail "T5: Appel Devstral échoué"
fi
}
test_handoff() {
log_info "=== Test Suite: Handoff Sentinelle → Devstral ==="
# T6: Sentinelle détecte anomalie → Devstral génère fix
log_info "T6: Phase 1 — Sentinelle détecte anomalie"
local sentinelle_resp
if sentinelle_resp=$(call_agent "${SENTINELLE_ID}" \
"Analyse cette erreur et formule un diagnostic précis en une phrase pour transmission à l'agent de code: 'TypeError: NoneType has no attribute split in router/providers/mistral_agents_api.py line 142. Le champ agent_id peut être None quand l'env var est absente.'" \
"handoff-sentinelle" 2>/dev/null); then
local diagnosis
diagnosis=$(extract_content "${sentinelle_resp}")
if [[ -n "${diagnosis}" && "${#diagnosis}" -gt 20 ]]; then
log_pass "T6.1: Sentinelle diagnostic OK"
# Phase 2: Devstral reçoit le diagnostic et génère un fix
log_info "T6: Phase 2 — Devstral génère fix basé sur diagnostic"
local devstral_resp
if devstral_resp=$(call_agent "${DEVSTRAL_ID}" \
"Un agent de monitoring a détecté ce problème: ${diagnosis}. Génère un patch Python minimal pour corriger le bug (ajout d'une validation None-check avant l'appel .split())." \
"handoff-devstral" 2>/dev/null); then
local fix
fix=$(extract_content "${devstral_resp}")
if [[ -n "${fix}" && "${#fix}" -gt 30 ]]; then
log_pass "T6.2: Devstral fix généré — Handoff complet ✓"
else
log_fail "T6.2: Devstral fix insuffisant"
fi
else
log_fail "T6.2: Appel Devstral handoff échoué"
fi
else
log_fail "T6.1: Sentinelle diagnostic vide"
fi
else
log_fail "T6.1: Appel Sentinelle handoff échoué"
fi
}
# ── Rapport ───────────────────────────────────────────────────────────────
emit_report() {
local total=$((PASS + FAIL + SKIP))
if [[ "${JSON_MODE}" -eq 1 ]]; then
python3 -c "
import json
print(json.dumps({
'contract': 'cockpit-v1',
'tool': 'e2e_agents_test',
'timestamp': '${STAMP}',
'action': '${ACTION}',
'results': {
'total': ${total},
'pass': ${PASS},
'fail': ${FAIL},
'skip': ${SKIP},
'success_rate': round(${PASS} / max(${total}, 1) * 100, 1)
},
'agents_tested': {
'sentinelle': '${SENTINELLE_ID}',
'tower': '${TOWER_ID}',
'forge': '${FORGE_ID}',
'devstral': '${DEVSTRAL_ID}'
},
'api_mode': '${API_MODE}',
'log_file': '${LOG_FILE}'
}, indent=2))
"
else
echo ""
echo "══════════════════════════════════════════════"
printf " E2E Agents Test Report — %s\n" "${STAMP}"
echo "══════════════════════════════════════════════"
printf " API Mode: %s\n" "${API_MODE}"
printf " Total: %d | ${GREEN}Pass: %d${NC} | ${RED}Fail: %d${NC} | ${YELLOW}Skip: %d${NC}\n" \
"${total}" "${PASS}" "${FAIL}" "${SKIP}"
printf " Success rate: %.1f%%\n" "$(python3 -c "print(${PASS}/max(${total},1)*100)")"
echo " Log: ${LOG_FILE}"
echo "══════════════════════════════════════════════"
fi
}
# ── Main ──────────────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--action) ACTION="${2:-}"; shift 2 ;;
--api-mode) API_MODE="${2:-beta}"; shift 2 ;;
--json) JSON_MODE=1; shift ;;
--timeout) TIMEOUT="${2:-30}"; shift 2 ;;
--verbose) VERBOSE=1; shift ;;
--help) usage; exit 0 ;;
*) printf 'Unknown: %s\n' "$1" >&2; usage >&2; exit 2 ;;
esac
done
if [[ -z "${ACTION}" ]]; then
ACTION="all"
fi
exec > >(tee -a "${LOG_FILE}") 2>&1
printf '[e2e-agents-test] action=%s timestamp=%s\n' "${ACTION}" "${STAMP}"
if ! check_api_key; then
emit_report
exit 1
fi
case "${ACTION}" in
all)
test_sentinelle
test_tower
test_forge
test_devstral
test_handoff
;;
sentinelle) test_sentinelle ;;
tower) test_tower ;;
forge) test_forge ;;
devstral) test_devstral ;;
handoff) test_handoff ;;
*)
printf 'Unknown action: %s\n' "${ACTION}" >&2
usage >&2
exit 2
;;
esac
emit_report
# Exit code basé sur le résultat
if [[ "${FAIL}" -gt 0 ]]; then
exit 1
fi
exit 0
+618
View File
@@ -0,0 +1,618 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ARTIFACTS_ROOT="${ROOT_DIR}/artifacts/cockpit/fab_package"
SCHEMA_PATH="${ROOT_DIR}/specs/contracts/fab_package.schema.json"
SCHOPS="${ROOT_DIR}/tools/hw/schops/schops.py"
YIACAD="${ROOT_DIR}/tools/cad/yiacad_native_ops.py"
ACTION="summary"
JSON_MODE=0
BOARD_ID=""
SOURCE_SCHEMATIC=""
SOURCE_BOARD=""
ROUTE_ORIGIN="local"
MODE="dry"
usage() {
cat <<'EOF'
Usage: fab_package_tui.sh [--action summary|build|validate|artifacts] [--json]
[--board-id ID] [--schematic PATH] [--board PATH]
[--route-origin local|quilter|pcbdesigner] [--mode dry|live]
EOF
}
now_stamp() {
date '+%Y%m%d_%H%M%S'
}
ensure_dir() {
mkdir -p "$1"
}
json_escape() {
python3 - "$1" <<'PY'
import json
import sys
print(json.dumps(sys.argv[1]))
PY
}
resolve_kicad_cli() {
if [[ -n "${KICAD_CLI:-}" && -x "${KICAD_CLI}" ]]; then
printf '%s\n' "${KICAD_CLI}"
return 0
fi
if [[ -x /Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli ]]; then
printf '%s\n' /Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli
return 0
fi
command -v kicad-cli 2>/dev/null || true
}
derive_board_id() {
if [[ -n "${BOARD_ID}" ]]; then
printf '%s\n' "${BOARD_ID}"
return
fi
if [[ -n "${SOURCE_BOARD}" ]]; then
basename "${SOURCE_BOARD}" .kicad_pcb
return
fi
if [[ -n "${SOURCE_SCHEMATIC}" ]]; then
basename "${SOURCE_SCHEMATIC}" .kicad_sch
return
fi
printf '%s\n' unknown-board
}
abs_or_empty() {
local path="$1"
if [[ -n "${path}" && -e "${path}" ]]; then
python3 - "${path}" <<'PY'
from pathlib import Path
import sys
print(Path(sys.argv[1]).resolve())
PY
else
printf '%s' ""
fi
}
latest_json_path() {
printf '%s\n' "${ARTIFACTS_ROOT}/latest.json"
}
latest_md_path() {
printf '%s\n' "${ARTIFACTS_ROOT}/latest.md"
}
write_latest() {
local json_path="$1"
local md_path="$2"
ensure_dir "${ARTIFACTS_ROOT}"
cp "${json_path}" "$(latest_json_path)"
cp "${md_path}" "$(latest_md_path)"
}
emit_no_latest() {
local status="degraded"
local reason="no fab package artifact has been generated yet"
if [[ "${JSON_MODE}" -eq 1 ]]; then
cat <<EOF
{
"contract_version": "fab-package-v1",
"generated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"status": "${status}",
"board_id": "",
"source_schematic": null,
"source_board": null,
"route_origin": "local",
"bom_file": null,
"cpl_file": null,
"gerber_dir": null,
"drill_file": null,
"drc_report": null,
"review_artifacts": [],
"provenance": {
"producer": "fab_package_tui.sh",
"tool": "cockpit",
"mode": "dry",
"route_origin": "local"
},
"acceptance_gates": {
"erc_ok": false,
"drc_ok": false,
"bom_review_ok": false,
"artifacts_complete": false
},
"degraded_reasons": [$(json_escape "${reason}")],
"next_steps": [
"run fab_package_tui.sh --action build with a valid KiCad schematic or board",
"materialize Hypnoled assets in the current checkout before running T-HP lots"
],
"artifacts": []
}
EOF
else
printf 'Fab package status: %s\n' "${status}"
printf -- '- reason: %s\n' "${reason}"
fi
}
run_capture() {
local log_prefix="$1"
shift
set +e
"$@" >"${log_prefix}.stdout.log" 2>"${log_prefix}.stderr.log"
local rc=$?
set -e
return "${rc}"
}
build_package() {
local board_id run_stamp run_dir hw_root hw_latest kicad_cli
local bom_file="" cpl_file="" gerber_dir="" drill_file="" drc_report="" netlist_file=""
local yiacad_erc_drc="" yiacad_bom_review=""
local status="blocked"
local erc_ok=0 drc_ok=0 bom_review_ok=0 artifacts_complete=0
local reasons=()
local next_steps=()
local artifacts=()
board_id="$(derive_board_id)"
run_stamp="$(now_stamp)"
run_dir="${ARTIFACTS_ROOT}/${run_stamp}-${board_id}"
hw_root="${run_dir}/hw"
ensure_dir "${run_dir}"
ensure_dir "${hw_root}"
if [[ -z "${SOURCE_SCHEMATIC}" && -z "${SOURCE_BOARD}" ]]; then
reasons+=("missing-source-files")
next_steps+=("provide --schematic and/or --board")
fi
if [[ -n "${SOURCE_SCHEMATIC}" && ! -f "${SOURCE_SCHEMATIC}" ]]; then
reasons+=("schematic-not-found")
next_steps+=("provide an existing .kicad_sch file")
SOURCE_SCHEMATIC=""
fi
if [[ -n "${SOURCE_BOARD}" && ! -f "${SOURCE_BOARD}" ]]; then
reasons+=("board-not-found")
next_steps+=("provide an existing .kicad_pcb file")
SOURCE_BOARD=""
fi
if [[ -n "${SOURCE_SCHEMATIC}" ]]; then
if run_capture "${run_dir}/erc" python3 "${SCHOPS}" --artifacts "${hw_root}" erc --schematic "${SOURCE_SCHEMATIC}"; then
erc_ok=1
else
reasons+=("schops-erc-failed")
fi
if ! run_capture "${run_dir}/netlist" python3 "${SCHOPS}" --artifacts "${hw_root}" netlist --schematic "${SOURCE_SCHEMATIC}"; then
reasons+=("schops-netlist-failed")
fi
if run_capture "${run_dir}/bom" python3 "${SCHOPS}" --artifacts "${hw_root}" bom --schematic "${SOURCE_SCHEMATIC}"; then
:
else
reasons+=("schops-bom-failed")
fi
hw_latest="$(find "${hw_root}" -mindepth 1 -maxdepth 1 -type d | sort | tail -n 1 || true)"
if [[ -n "${hw_latest}" ]]; then
bom_file="$(find "${hw_latest}" -type f \( -name '*.csv' -o -name '*bom*' \) | sort | head -n 1 || true)"
netlist_file="$(find "${hw_latest}" -type f \( -name '*.net' -o -name '*.xml' -o -name '*netlist*' \) | sort | head -n 1 || true)"
artifacts+=("${hw_latest}")
fi
if run_capture "${run_dir}/yiacad_erc_drc" python3 "${YIACAD}" kicad-erc-drc --source-path "${SOURCE_SCHEMATIC}" --json-output; then
drc_ok=1
yiacad_erc_drc="${run_dir}/yiacad_erc_drc.stdout.log"
else
reasons+=("yiacad-erc-drc-failed")
yiacad_erc_drc="${run_dir}/yiacad_erc_drc.stderr.log"
fi
if run_capture "${run_dir}/yiacad_bom_review" python3 "${YIACAD}" bom-review --source-path "${SOURCE_SCHEMATIC}" --json-output; then
bom_review_ok=1
yiacad_bom_review="${run_dir}/yiacad_bom_review.stdout.log"
else
reasons+=("yiacad-bom-review-failed")
yiacad_bom_review="${run_dir}/yiacad_bom_review.stderr.log"
fi
fi
if [[ -n "${SOURCE_BOARD}" ]]; then
kicad_cli="$(resolve_kicad_cli)"
if [[ -n "${kicad_cli}" ]]; then
ensure_dir "${run_dir}/gerbers"
ensure_dir "${run_dir}/drill"
if run_capture "${run_dir}/gerber_export" "${kicad_cli}" pcb export gerbers -o "${run_dir}/gerbers" "${SOURCE_BOARD}"; then
gerber_dir="${run_dir}/gerbers"
else
reasons+=("gerber-export-failed")
fi
if run_capture "${run_dir}/drill_export" "${kicad_cli}" pcb export drill -o "${run_dir}/drill" "${SOURCE_BOARD}"; then
drill_file="$(find "${run_dir}/drill" -type f | sort | head -n 1 || true)"
else
reasons+=("drill-export-failed")
fi
else
reasons+=("kicad-cli-unavailable")
next_steps+=("install KiCad CLI or use a machine with host KiCad access")
fi
fi
if [[ -z "${cpl_file}" ]]; then
reasons+=("cpl-export-missing")
next_steps+=("add a canonical local CPL export before assembly-ready handoff")
fi
if [[ -n "${bom_file}" && -n "${gerber_dir}" && -n "${drill_file}" && -n "${cpl_file}" ]]; then
artifacts_complete=1
fi
if [[ ${erc_ok} -eq 1 && ${drc_ok} -eq 1 && ${bom_review_ok} -eq 1 && ${artifacts_complete} -eq 1 ]]; then
status="ready"
elif [[ -n "${SOURCE_SCHEMATIC}" || -n "${SOURCE_BOARD}" ]]; then
status="degraded"
fi
local json_path="${run_dir}/fab_package_${run_stamp}.json"
local md_path="${run_dir}/fab_package_${run_stamp}.md"
python3 - "${json_path}" "${md_path}" <<'PY'
import json
import os
import pathlib
import sys
json_path = pathlib.Path(sys.argv[1])
md_path = pathlib.Path(sys.argv[2])
def env(name, default=""):
return os.environ.get(name, default)
reasons = [item for item in env("FAB_REASONS", "").split("\n") if item]
next_steps = [item for item in env("FAB_NEXT_STEPS", "").split("\n") if item]
review_artifacts = [item for item in env("FAB_REVIEW_ARTIFACTS", "").split("\n") if item]
artifacts = []
for item in [x for x in env("FAB_ARTIFACTS", "").split("\n") if x]:
artifacts.append({"path": item})
payload = {
"contract_version": "fab-package-v1",
"generated_at": env("FAB_GENERATED_AT"),
"status": env("FAB_STATUS"),
"board_id": env("FAB_BOARD_ID"),
"source_schematic": env("FAB_SOURCE_SCHEMATIC") or None,
"source_board": env("FAB_SOURCE_BOARD") or None,
"route_origin": env("FAB_ROUTE_ORIGIN"),
"bom_file": env("FAB_BOM_FILE") or None,
"cpl_file": env("FAB_CPL_FILE") or None,
"gerber_dir": env("FAB_GERBER_DIR") or None,
"drill_file": env("FAB_DRILL_FILE") or None,
"drc_report": env("FAB_DRC_REPORT") or None,
"netlist_file": env("FAB_NETLIST_FILE") or None,
"review_artifacts": review_artifacts,
"provenance": {
"producer": "tools/cockpit/fab_package_tui.sh",
"tool": "fab-package-local-chain",
"mode": env("FAB_MODE"),
"route_origin": env("FAB_ROUTE_ORIGIN"),
},
"acceptance_gates": {
"erc_ok": env("FAB_ERC_OK") == "1",
"drc_ok": env("FAB_DRC_OK") == "1",
"bom_review_ok": env("FAB_BOM_REVIEW_OK") == "1",
"artifacts_complete": env("FAB_ARTIFACTS_COMPLETE") == "1",
},
"degraded_reasons": reasons,
"next_steps": next_steps,
"artifacts": artifacts,
}
json_path.write_text(json.dumps(payload, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
lines = [
"# Fab package",
"",
f"- status: {payload['status']}",
f"- board_id: {payload['board_id']}",
f"- route_origin: {payload['route_origin']}",
f"- source_schematic: {payload['source_schematic'] or ''}",
f"- source_board: {payload['source_board'] or ''}",
f"- bom_file: {payload['bom_file'] or ''}",
f"- cpl_file: {payload['cpl_file'] or ''}",
f"- gerber_dir: {payload['gerber_dir'] or ''}",
f"- drill_file: {payload['drill_file'] or ''}",
f"- drc_report: {payload['drc_report'] or ''}",
"",
"## Acceptance gates",
"",
]
for key, value in payload["acceptance_gates"].items():
lines.append(f"- {key}: {str(value).lower()}")
lines += ["", "## Reasons", ""]
if reasons:
lines.extend(f"- {item}" for item in reasons)
else:
lines.append("- none")
lines += ["", "## Next steps", ""]
if next_steps:
lines.extend(f"- {item}" for item in next_steps)
else:
lines.append("- none")
md_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
FAB_GENERATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
FAB_STATUS="${status}" \
FAB_BOARD_ID="${board_id}" \
FAB_SOURCE_SCHEMATIC="$(abs_or_empty "${SOURCE_SCHEMATIC}")" \
FAB_SOURCE_BOARD="$(abs_or_empty "${SOURCE_BOARD}")" \
FAB_ROUTE_ORIGIN="${ROUTE_ORIGIN}" \
FAB_BOM_FILE="$(abs_or_empty "${bom_file}")" \
FAB_CPL_FILE="$(abs_or_empty "${cpl_file}")" \
FAB_GERBER_DIR="$(abs_or_empty "${gerber_dir}")" \
FAB_DRILL_FILE="$(abs_or_empty "${drill_file}")" \
FAB_DRC_REPORT="$(abs_or_empty "${yiacad_erc_drc}")" \
FAB_NETLIST_FILE="$(abs_or_empty "${netlist_file}")" \
FAB_MODE="${MODE}" \
FAB_ERC_OK="${erc_ok}" \
FAB_DRC_OK="${drc_ok}" \
FAB_BOM_REVIEW_OK="${bom_review_ok}" \
FAB_ARTIFACTS_COMPLETE="${artifacts_complete}" \
FAB_REASONS="$(printf '%s\n' "${reasons[@]-}")" \
FAB_NEXT_STEPS="$(printf '%s\n' "${next_steps[@]-}")" \
FAB_REVIEW_ARTIFACTS="$(printf '%s\n' "${yiacad_erc_drc}" "${yiacad_bom_review}")" \
FAB_ARTIFACTS="$(printf '%s\n' "${artifacts[@]-}")" \
python3 - "${json_path}" "${md_path}" <<'PY'
import json
import os
import pathlib
import sys
json_path = pathlib.Path(sys.argv[1])
md_path = pathlib.Path(sys.argv[2])
def env(name, default=""):
return os.environ.get(name, default)
reasons = [item for item in env("FAB_REASONS", "").split("\n") if item]
next_steps = [item for item in env("FAB_NEXT_STEPS", "").split("\n") if item]
review_artifacts = [item for item in env("FAB_REVIEW_ARTIFACTS", "").split("\n") if item]
artifacts = []
for item in [x for x in env("FAB_ARTIFACTS", "").split("\n") if x]:
artifacts.append({"path": item})
payload = {
"contract_version": "fab-package-v1",
"generated_at": env("FAB_GENERATED_AT"),
"status": env("FAB_STATUS"),
"board_id": env("FAB_BOARD_ID"),
"source_schematic": env("FAB_SOURCE_SCHEMATIC") or None,
"source_board": env("FAB_SOURCE_BOARD") or None,
"route_origin": env("FAB_ROUTE_ORIGIN"),
"bom_file": env("FAB_BOM_FILE") or None,
"cpl_file": env("FAB_CPL_FILE") or None,
"gerber_dir": env("FAB_GERBER_DIR") or None,
"drill_file": env("FAB_DRILL_FILE") or None,
"drc_report": env("FAB_DRC_REPORT") or None,
"netlist_file": env("FAB_NETLIST_FILE") or None,
"review_artifacts": review_artifacts,
"provenance": {
"producer": "tools/cockpit/fab_package_tui.sh",
"tool": "fab-package-local-chain",
"mode": env("FAB_MODE"),
"route_origin": env("FAB_ROUTE_ORIGIN"),
},
"acceptance_gates": {
"erc_ok": env("FAB_ERC_OK") == "1",
"drc_ok": env("FAB_DRC_OK") == "1",
"bom_review_ok": env("FAB_BOM_REVIEW_OK") == "1",
"artifacts_complete": env("FAB_ARTIFACTS_COMPLETE") == "1",
},
"degraded_reasons": reasons,
"next_steps": next_steps,
"artifacts": artifacts,
}
json_path.write_text(json.dumps(payload, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
lines = [
"# Fab package",
"",
f"- status: {payload['status']}",
f"- board_id: {payload['board_id']}",
f"- route_origin: {payload['route_origin']}",
f"- source_schematic: {payload['source_schematic'] or ''}",
f"- source_board: {payload['source_board'] or ''}",
f"- bom_file: {payload['bom_file'] or ''}",
f"- cpl_file: {payload['cpl_file'] or ''}",
f"- gerber_dir: {payload['gerber_dir'] or ''}",
f"- drill_file: {payload['drill_file'] or ''}",
f"- drc_report: {payload['drc_report'] or ''}",
"",
"## Acceptance gates",
"",
]
for key, value in payload["acceptance_gates"].items():
lines.append(f"- {key}: {str(value).lower()}")
lines += ["", "## Reasons", ""]
if reasons:
lines.extend(f"- {item}" for item in reasons)
else:
lines.append("- none")
lines += ["", "## Next steps", ""]
if next_steps:
lines.extend(f"- {item}" for item in next_steps)
else:
lines.append("- none")
md_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
write_latest "${json_path}" "${md_path}"
if [[ "${JSON_MODE}" -eq 1 ]]; then
cat "${json_path}"
else
cat "${md_path}"
fi
}
validate_package() {
local latest
latest="$(latest_json_path)"
if [[ ! -f "${latest}" ]]; then
emit_no_latest
return 1
fi
python3 - "${latest}" "${SCHEMA_PATH}" "${JSON_MODE}" <<'PY'
import json
import pathlib
import sys
latest = pathlib.Path(sys.argv[1])
schema = pathlib.Path(sys.argv[2])
as_json = sys.argv[3] == "1"
payload = json.loads(latest.read_text(encoding="utf-8"))
schema_payload = json.loads(schema.read_text(encoding="utf-8"))
required = schema_payload.get("required", [])
missing = [field for field in required if field not in payload]
paths_to_check = {
"bom_file": payload.get("bom_file"),
"cpl_file": payload.get("cpl_file"),
"gerber_dir": payload.get("gerber_dir"),
"drill_file": payload.get("drill_file"),
"drc_report": payload.get("drc_report"),
}
missing_paths = []
for key, value in paths_to_check.items():
if value and not pathlib.Path(value).exists():
missing_paths.append(key)
result = {
"status": "ok" if not missing and not missing_paths else "degraded",
"latest_json": str(latest),
"missing_fields": missing,
"missing_paths": missing_paths,
"acceptance_gates": payload.get("acceptance_gates", {}),
}
if as_json:
print(json.dumps(result, indent=2, ensure_ascii=True))
else:
print("Fab package validation")
print(f"- status: {result['status']}")
print("- missing_fields: " + (", ".join(result["missing_fields"]) or "none"))
print("- missing_paths: " + (", ".join(result["missing_paths"]) or "none"))
PY
}
summary_package() {
local latest
latest="$(latest_json_path)"
if [[ ! -f "${latest}" ]]; then
emit_no_latest
return 0
fi
if [[ "${JSON_MODE}" -eq 1 ]]; then
cat "${latest}"
else
cat "$(latest_md_path)"
fi
}
artifacts_package() {
local latest latest_md
latest="$(latest_json_path)"
latest_md="$(latest_md_path)"
if [[ "${JSON_MODE}" -eq 1 ]]; then
cat <<EOF
{
"status": "ok",
"schema": $(json_escape "${SCHEMA_PATH}"),
"latest_json": $(json_escape "${latest}"),
"latest_markdown": $(json_escape "${latest_md}")
}
EOF
else
printf 'Fab package artifacts\n'
printf -- '- schema: %s\n' "${SCHEMA_PATH}"
printf -- '- latest_json: %s\n' "${latest}"
printf -- '- latest_markdown: %s\n' "${latest_md}"
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--json)
JSON_MODE=1
shift
;;
--board-id)
BOARD_ID="${2:-}"
shift 2
;;
--schematic)
SOURCE_SCHEMATIC="${2:-}"
shift 2
;;
--board)
SOURCE_BOARD="${2:-}"
shift 2
;;
--route-origin)
ROUTE_ORIGIN="${2:-}"
shift 2
;;
--mode)
MODE="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
case "${ACTION}" in
summary)
summary_package
;;
build)
build_package
;;
validate)
validate_package
;;
artifacts)
artifacts_package
;;
*)
echo "Unknown action: ${ACTION}" >&2
usage >&2
exit 1
;;
esac
+853
View File
@@ -0,0 +1,853 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
source "${PROJECT_DIR}/tools/cockpit/json_contract.sh"
LOG_DIR="${PROJECT_DIR}/artifacts/cockpit"
STATE_DIR="${PROJECT_DIR}/artifacts/operator_lane"
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
LOG_FILE="${LOG_DIR}/full_operator_lane_${TIMESTAMP}.log"
SUMMARY_FILE="${STATE_DIR}/full_operator_lane_${TIMESTAMP}.json"
MASCARADE_HEALTH_FILE="${STATE_DIR}/full_operator_lane_mascarade_health_${TIMESTAMP}.json"
MASCARADE_LOGS_FILE="${STATE_DIR}/full_operator_lane_mascarade_logs_${TIMESTAMP}.json"
MASCARADE_QUEUE_FILE="${STATE_DIR}/full_operator_lane_mascarade_queue_${TIMESTAMP}.json"
MASCARADE_WATCH_FILE="${STATE_DIR}/full_operator_lane_mascarade_watch_${TIMESTAMP}.json"
DAILY_OPERATOR_SUMMARY_FILE="${STATE_DIR}/full_operator_lane_daily_operator_summary_${TIMESTAMP}.json"
ROUTING_FILE="${STATE_DIR}/full_operator_lane_routing_${TIMESTAMP}.json"
KILL_LIFE_MEMORY_FILE="${STATE_DIR}/full_operator_lane_kill_life_memory_${TIMESTAMP}.json"
API_BASE="${CRAZY_LIFE_API_BASE:-http://localhost:3100/api/killlife}"
WORKFLOW_ID="${FULL_OPERATOR_LANE_WORKFLOW_ID:-embedded-operator-live}"
WAIT_SECONDS="${FULL_OPERATOR_LANE_POLL_SECONDS:-2}"
WAIT_ATTEMPTS="${FULL_OPERATOR_LANE_POLL_ATTEMPTS:-20}"
JSON_OUTPUT=0
COMMAND="status"
LOGS_ACTION="summary"
MASCARADE_HEALTH_STATUS="unknown"
MASCARADE_PROVIDER="unknown"
MASCARADE_MODEL="unknown"
MASCARADE_LOGS_STATUS="unknown"
MASCARADE_LOGS_STALE="0"
MASCARADE_LOGS_PURGED="0"
MASCARADE_QUEUE_STATUS="unknown"
MASCARADE_QUEUE_MARKDOWN=""
MASCARADE_WATCH_STATUS="unknown"
MASCARADE_WATCH_MARKDOWN=""
DAILY_OPERATOR_SUMMARY_STATUS="unknown"
DAILY_OPERATOR_SUMMARY_MARKDOWN=""
ROUTING_STATUS="unknown"
KILL_LIFE_MEMORY_STATUS="unknown"
COMMAND_EXIT_CODE=0
mkdir -p "${LOG_DIR}" "${STATE_DIR}"
usage() {
cat <<'USAGE'
Usage: bash tools/cockpit/full_operator_lane.sh <status|dry-run|live|all|logs|purge> [--json] [--logs-action <summary|latest|list|purge>]
Commands:
status Fetch workflow detail and latest runs.
dry-run Validate then trigger a dry-run on the operator lane.
live Validate then trigger the live-provider execution path.
all Run dry-run then live in sequence.
logs Read or purge Mascarade/Ollama logs through the operator lane.
purge Remove generated operator-lane logs and summaries.
Options:
--json Print the generated summary JSON to stdout.
--logs-action <name> summary|latest|list|purge for the `logs` command (default: summary).
USAGE
}
log_line() {
local level="$1"
shift
mkdir -p "${LOG_DIR}" "${STATE_DIR}"
local msg="${level} $(date '+%Y-%m-%d %H:%M:%S %z') $*"
printf '%s\n' "${msg}" | tee -a "${LOG_FILE}" >/dev/null
}
curl_json() {
local method="$1"
local url="$2"
local body="$3"
local output_file="$4"
local http_file
local http_code
http_file="$(mktemp)"
local -a args=(curl -sS -X "${method}" -H 'Content-Type: application/json' -o "${output_file}" -w '%{http_code}')
local gateway_api_key="${CRAZY_LIFE_API_KEY:-${MASCARADE_API_KEY:-${KILL_LIFE_API_KEY:-}}}"
if [[ -n "${gateway_api_key}" ]]; then
args+=(-H "Authorization: Bearer ${gateway_api_key}")
fi
if [[ -n "${body}" ]]; then
args+=(--data "${body}")
fi
args+=("${url}")
if ! "${args[@]}" > "${http_file}" 2>>"${LOG_FILE}"; then
rm -f "${http_file}"
return 1
fi
http_code="$(tr -d '\n' < "${http_file}")"
rm -f "${http_file}"
if [[ ! "${http_code}" =~ ^2[0-9][0-9]$ ]]; then
return 1
fi
}
write_failure_summary() {
local output_file="$1"
local error_code="$2"
local url="$3"
local hint=""
local suggested_command=""
case "${error_code}" in
status-api-unreachable)
hint="API locale indisponible pour lire le workflow operateur."
suggested_command="export CRAZY_LIFE_API_BASE=http://<host>:3100/api/killlife"
;;
validate-api-unreachable)
hint="Impossible de valider le workflow tant que l'API locale n'est pas joignable."
suggested_command="bash tools/cockpit/full_operator_lane.sh status --json"
;;
run-api-unreachable)
hint="Le declenchement dry-run/live ne peut pas partir sans API locale joignable."
suggested_command="bash tools/cockpit/full_operator_lane.sh status --json"
;;
poll-api-unreachable)
hint="Le workflow a peut-etre demarre mais le polling ne peut plus joindre l'API."
suggested_command="bash tools/cockpit/full_operator_lane.sh status --json"
;;
esac
printf '{"status":"failed","error":"%s","url":"%s","command":"%s","workflow_id":"%s","hint":"%s","suggested_command":"%s"}\n' \
"${error_code}" "${url}" "${COMMAND}" "${WORKFLOW_ID}" "${hint}" "${suggested_command}" > "${output_file}"
}
json_get() {
local file="$1"
local path_expr="$2"
python3 - "$file" "$path_expr" <<'PY'
import json
import sys
from pathlib import Path
file_path = Path(sys.argv[1])
path = sys.argv[2].split('.') if sys.argv[2] else []
text = file_path.read_text(encoding='utf-8')
try:
value = json.loads(text)
except json.JSONDecodeError:
decoder = json.JSONDecoder()
idx = 0
last_value = None
while idx < len(text):
while idx < len(text) and text[idx].isspace():
idx += 1
if idx >= len(text):
break
try:
candidate, end = decoder.raw_decode(text, idx)
except json.JSONDecodeError:
break
last_value = candidate
idx = end
if last_value is None:
print("")
raise SystemExit(0)
value = last_value
for key in path:
if isinstance(value, dict):
value = value.get(key)
else:
value = None
break
if value is None:
print("")
elif isinstance(value, (dict, list)):
print(json.dumps(value, ensure_ascii=True))
else:
print(value)
PY
}
emit_contract_json() {
python3 - "${SUMMARY_FILE}" "${COMMAND}" "${LOGS_ACTION}" "${LOG_FILE}" "${WORKFLOW_ID}" "${API_BASE}" "${MASCARADE_HEALTH_FILE}" "${MASCARADE_HEALTH_STATUS}" "${MASCARADE_PROVIDER}" "${MASCARADE_MODEL}" "${MASCARADE_LOGS_FILE}" "${MASCARADE_LOGS_STATUS}" "${MASCARADE_LOGS_STALE}" "${MASCARADE_LOGS_PURGED}" "${MASCARADE_QUEUE_FILE}" "${MASCARADE_QUEUE_STATUS}" "${MASCARADE_QUEUE_MARKDOWN}" "${MASCARADE_WATCH_FILE}" "${MASCARADE_WATCH_STATUS}" "${MASCARADE_WATCH_MARKDOWN}" "${DAILY_OPERATOR_SUMMARY_FILE}" "${DAILY_OPERATOR_SUMMARY_STATUS}" "${DAILY_OPERATOR_SUMMARY_MARKDOWN}" "${ROUTING_FILE}" "${ROUTING_STATUS}" "${KILL_LIFE_MEMORY_FILE}" "${KILL_LIFE_MEMORY_STATUS}" "${PRODUCT_CONTRACT_HANDOFF_FILE:-}" "${PRODUCT_CONTRACT_HANDOFF_STATUS:-unknown}" "${PRODUCT_CONTRACT_HANDOFF_MARKDOWN:-}" <<'PY'
import json
import sys
from pathlib import Path
summary_path = Path(sys.argv[1])
command = sys.argv[2]
logs_action = sys.argv[3]
log_file = sys.argv[4]
workflow_id = sys.argv[5]
api_base = sys.argv[6]
health_path = Path(sys.argv[7])
health_status = sys.argv[8]
health_provider = sys.argv[9]
health_model = sys.argv[10]
logs_path = Path(sys.argv[11])
logs_status = sys.argv[12]
logs_stale = sys.argv[13]
logs_purged = sys.argv[14]
queue_path = Path(sys.argv[15])
queue_status = sys.argv[16]
queue_markdown = sys.argv[17]
watch_path = Path(sys.argv[18])
watch_status = sys.argv[19]
watch_markdown = sys.argv[20]
daily_summary_path = Path(sys.argv[21])
daily_summary_status = sys.argv[22]
daily_summary_markdown = sys.argv[23]
routing_path = Path(sys.argv[24])
routing_status = sys.argv[25]
memory_path = Path(sys.argv[26])
memory_status = sys.argv[27]
handoff_path = Path(sys.argv[28]) if sys.argv[28] else Path("")
handoff_status = sys.argv[29]
handoff_markdown = sys.argv[30]
summary = {}
if summary_path.exists():
raw = summary_path.read_text(encoding="utf-8")
try:
summary = json.loads(raw)
except json.JSONDecodeError:
summary = {"raw_summary": raw}
raw_status = ""
error_code = ""
error_url = ""
hint = ""
suggested_command = ""
if isinstance(summary, dict):
raw_status = str(summary.get("status", ""))
error_code = str(summary.get("error", ""))
error_url = str(summary.get("url", ""))
hint = str(summary.get("hint", ""))
suggested_command = str(summary.get("suggested_command", ""))
def map_status(value: str) -> str:
value = (value or "").strip().lower()
if value in {"done", "ready", "ok", "success"}:
return "ok"
if value in {"failed", "error", "cancelled", "cancel_unresolved"}:
return "error"
if value in {"", "unknown"}:
return "degraded"
return "degraded"
contract_status = map_status(raw_status)
reasons = []
next_steps = []
if contract_status != "ok":
reasons.append(error_code or f"operator-lane-{raw_status or command}")
if error_url:
next_steps.append(f"Restore or reroute the local API endpoint {error_url}")
if hint:
next_steps.append(hint)
if suggested_command:
next_steps.append(suggested_command)
next_steps.append(f"Inspect {log_file} and {summary_path}")
health_summary = {}
if health_path.exists():
raw = health_path.read_text(encoding="utf-8")
try:
health_summary = json.loads(raw)
except json.JSONDecodeError:
health_summary = {"raw_health_summary": raw}
if (health_status or "").strip().lower() not in {"ok", "ready", "done", "success", "skipped", "n/a"}:
if contract_status == "ok":
contract_status = "degraded"
reasons.append(f"mascarade-runtime-{health_status or 'unknown'}")
next_steps.append(f"Inspect {health_path}")
logs_summary = {}
if logs_path.exists():
raw = logs_path.read_text(encoding="utf-8")
try:
logs_summary = json.loads(raw)
except json.JSONDecodeError:
logs_summary = {"raw_logs_summary": raw}
if (logs_status or "").strip().lower() not in {"ok", "ready", "done", "success", "skipped", "n/a"}:
if contract_status == "ok":
contract_status = "degraded"
reasons.append(f"mascarade-logs-{logs_status or 'unknown'}")
next_steps.append(f"Inspect {logs_path}")
queue_summary = {}
if queue_path.exists():
raw = queue_path.read_text(encoding="utf-8")
try:
queue_summary = json.loads(raw)
except json.JSONDecodeError:
queue_summary = {"raw_queue_summary": raw}
if (queue_status or "").strip().lower() not in {"ok", "ready", "done", "success", "skipped", "n/a"}:
if contract_status == "ok":
contract_status = "degraded"
reasons.append(f"mascarade-queue-{queue_status or 'unknown'}")
next_steps.append(f"Inspect {queue_path}")
watch_summary = {}
if watch_path.exists():
raw = watch_path.read_text(encoding="utf-8")
try:
watch_summary = json.loads(raw)
except json.JSONDecodeError:
watch_summary = {"raw_watch_summary": raw}
if (watch_status or "").strip().lower() not in {"ok", "ready", "done", "success", "skipped", "n/a"}:
if contract_status == "ok":
contract_status = "degraded"
reasons.append(f"mascarade-watch-{watch_status or 'unknown'}")
next_steps.append(f"Inspect {watch_path}")
daily_summary = {}
if daily_summary_path.exists():
raw = daily_summary_path.read_text(encoding="utf-8")
try:
daily_summary = json.loads(raw)
except json.JSONDecodeError:
daily_summary = {"raw_daily_summary": raw}
if (daily_summary_status or "").strip().lower() not in {"ok", "ready", "done", "success", "skipped", "n/a"}:
if contract_status == "ok":
contract_status = "degraded"
reasons.append(f"daily-operator-summary-{daily_summary_status or 'unknown'}")
next_steps.append(f"Inspect {daily_summary_path}")
routing_summary = {}
if routing_path.exists():
raw = routing_path.read_text(encoding="utf-8")
try:
routing_summary = json.loads(raw)
except json.JSONDecodeError:
routing_summary = {"raw_routing_summary": raw}
if (routing_status or "").strip().lower() not in {"ok", "ready", "done", "success", "skipped", "n/a"}:
if contract_status == "ok":
contract_status = "degraded"
reasons.append(f"mascarade-routing-{routing_status or 'unknown'}")
next_steps.append(f"Inspect {routing_path}")
memory_summary = {}
if memory_path.exists():
raw = memory_path.read_text(encoding="utf-8")
try:
memory_summary = json.loads(raw)
except json.JSONDecodeError:
memory_summary = {"raw_memory_summary": raw}
if (memory_status or "").strip().lower() not in {"ok", "ready", "done", "success", "skipped", "n/a"}:
if contract_status == "ok":
contract_status = "degraded"
reasons.append(f"kill-life-memory-{memory_status or 'unknown'}")
next_steps.append(f"Inspect {memory_path}")
handoff_summary = {}
if handoff_path and handoff_path.exists():
raw = handoff_path.read_text(encoding="utf-8")
try:
handoff_summary = json.loads(raw)
except json.JSONDecodeError:
handoff_summary = {"raw_handoff_summary": raw}
if (handoff_status or "").strip().lower() not in {"ok", "ready", "done", "success", "skipped", "n/a"}:
if contract_status == "ok":
contract_status = "degraded"
reasons.append(f"product-contract-handoff-{handoff_status or 'unknown'}")
next_steps.append(f"Inspect {handoff_path}")
priority_counts = daily_summary.get("priority_counts", {}) if isinstance(daily_summary.get("priority_counts"), dict) else {}
severity_counts = daily_summary.get("severity_counts", {}) if isinstance(daily_summary.get("severity_counts"), dict) else {}
memory_entry = memory_summary.get("entry", {}) if isinstance(memory_summary, dict) else {}
if not isinstance(memory_entry, dict):
memory_entry = {}
decision = memory_entry.get("decision") if isinstance(memory_entry.get("decision"), dict) else {
"action": f"operator-lane-{command}",
"reason": "Operator lane projected into kill_life continuity memory.",
}
owner = memory_entry.get("owner", "SyncOps")
resume_ref = memory_entry.get("resume_ref", f"kill-life:full-operator-lane:{workflow_id}:{command}")
trust_level = memory_entry.get("trust_level", "inferred")
payload = {
"contract_version": "cockpit-v1",
"component": "full_operator_lane",
"action": command,
"logs_action": logs_action,
"status": contract_status,
"contract_status": contract_status,
"workflow_id": workflow_id,
"api_base": api_base,
"log_file": log_file,
"summary_file": str(summary_path),
"owner": owner,
"decision": decision,
"resume_ref": resume_ref,
"trust_level": trust_level,
"mascarade_health_status": health_status or "unknown",
"mascarade_provider": health_provider or "unknown",
"mascarade_model": health_model or "unknown",
"mascarade_health_file": str(health_path),
"mascarade_logs_status": logs_status or "unknown",
"mascarade_logs_stale": logs_stale or "0",
"mascarade_logs_purged": logs_purged or "0",
"mascarade_logs_file": str(logs_path),
"mascarade_queue_status": queue_status or "unknown",
"mascarade_queue_file": str(queue_path),
"mascarade_queue_markdown": queue_markdown or "",
"mascarade_watch_status": watch_status or "unknown",
"mascarade_watch_file": str(watch_path),
"mascarade_watch_markdown": watch_markdown or "",
"routing_status": routing_status or "unknown",
"routing_file": str(routing_path),
"routing": routing_summary,
"memory_entry_status": memory_status or "unknown",
"memory_entry_file": str(memory_path),
"memory_entry": memory_entry,
"priority_counts": priority_counts,
"severity_counts": severity_counts,
"daily_operator_summary_status": daily_summary_status or "unknown",
"daily_operator_summary_file": str(daily_summary_path),
"daily_operator_summary_markdown": daily_summary_markdown or "",
"product_contract_handoff_status": handoff_status or "unknown",
"product_contract_handoff_artifact": str(handoff_path) if handoff_path else "",
"product_contract_handoff_markdown": handoff_markdown or "",
"artifacts": [log_file, str(summary_path), str(health_path), str(logs_path), str(queue_path), str(watch_path), str(daily_summary_path), str(routing_path), str(memory_path)] + ([str(handoff_path)] if handoff_path else []),
"degraded_reasons": reasons,
"next_steps": next_steps,
"summary": summary,
"mascarade_health_summary": health_summary,
"mascarade_logs_summary": logs_summary,
"mascarade_queue_summary": queue_summary,
"mascarade_watch_summary": watch_summary,
"daily_operator_summary": daily_summary,
"product_contract_handoff": handoff_summary,
}
print(json.dumps(payload, ensure_ascii=False))
PY
}
refresh_mascarade_health() {
if bash "${PROJECT_DIR}/tools/cockpit/mascarade_runtime_health.sh" --json > "${MASCARADE_HEALTH_FILE}" 2>>"${LOG_FILE}"; then
MASCARADE_HEALTH_STATUS="$(json_get "${MASCARADE_HEALTH_FILE}" 'status')"
MASCARADE_PROVIDER="$(json_get "${MASCARADE_HEALTH_FILE}" 'provider')"
MASCARADE_MODEL="$(json_get "${MASCARADE_HEALTH_FILE}" 'model')"
log_line INFO "mascarade health status=${MASCARADE_HEALTH_STATUS:-unknown} provider=${MASCARADE_PROVIDER:-unknown} model=${MASCARADE_MODEL:-unknown}"
else
MASCARADE_HEALTH_STATUS="failed"
MASCARADE_PROVIDER="unknown"
MASCARADE_MODEL="unknown"
log_line ERROR "mascarade health-check failed"
fi
[[ -n "${MASCARADE_HEALTH_STATUS}" ]] || MASCARADE_HEALTH_STATUS="unknown"
[[ -n "${MASCARADE_PROVIDER}" ]] || MASCARADE_PROVIDER="unknown"
[[ -n "${MASCARADE_MODEL}" ]] || MASCARADE_MODEL="unknown"
}
refresh_mascarade_logs() {
local mode="${1:-summary}"
local -a args=(bash "${PROJECT_DIR}/tools/cockpit/mascarade_logs_tui.sh" --action "${mode}" --json)
if [[ "${mode}" == "purge" ]]; then
args+=(--apply)
fi
if "${args[@]}" > "${MASCARADE_LOGS_FILE}" 2>>"${LOG_FILE}"; then
MASCARADE_LOGS_STATUS="$(json_get "${MASCARADE_LOGS_FILE}" 'status')"
MASCARADE_LOGS_STALE="$(json_get "${MASCARADE_LOGS_FILE}" 'stale_candidate_count')"
MASCARADE_LOGS_PURGED="$(json_get "${MASCARADE_LOGS_FILE}" 'purged_count')"
[[ -n "${MASCARADE_LOGS_STALE}" ]] || MASCARADE_LOGS_STALE="0"
[[ -n "${MASCARADE_LOGS_PURGED}" ]] || MASCARADE_LOGS_PURGED="0"
log_line INFO "mascarade logs mode=${mode} status=${MASCARADE_LOGS_STATUS:-unknown} stale=${MASCARADE_LOGS_STALE:-0} purged=${MASCARADE_LOGS_PURGED:-0}"
else
MASCARADE_LOGS_STATUS="failed"
MASCARADE_LOGS_STALE="0"
MASCARADE_LOGS_PURGED="0"
log_line ERROR "mascarade logs action failed mode=${mode}"
fi
[[ -n "${MASCARADE_LOGS_STATUS}" ]] || MASCARADE_LOGS_STATUS="unknown"
}
refresh_mascarade_queue() {
if bash "${PROJECT_DIR}/tools/cockpit/render_mascarade_incident_queue.sh" --json > "${MASCARADE_QUEUE_FILE}" 2>>"${LOG_FILE}"; then
MASCARADE_QUEUE_STATUS="$(json_get "${MASCARADE_QUEUE_FILE}" 'status')"
MASCARADE_QUEUE_MARKDOWN="$(json_get "${MASCARADE_QUEUE_FILE}" 'markdown_file')"
log_line INFO "mascarade queue status=${MASCARADE_QUEUE_STATUS:-unknown} markdown=${MASCARADE_QUEUE_MARKDOWN:-none}"
else
MASCARADE_QUEUE_STATUS="failed"
MASCARADE_QUEUE_MARKDOWN=""
log_line ERROR "mascarade queue render failed"
fi
[[ -n "${MASCARADE_QUEUE_STATUS}" ]] || MASCARADE_QUEUE_STATUS="unknown"
}
refresh_mascarade_watch() {
if bash "${PROJECT_DIR}/tools/cockpit/render_mascarade_incident_watch.sh" --json > "${MASCARADE_WATCH_FILE}" 2>>"${LOG_FILE}"; then
MASCARADE_WATCH_STATUS="$(json_get "${MASCARADE_WATCH_FILE}" 'status')"
MASCARADE_WATCH_MARKDOWN="$(json_get "${MASCARADE_WATCH_FILE}" 'markdown_file')"
log_line INFO "mascarade watch status=${MASCARADE_WATCH_STATUS:-unknown} markdown=${MASCARADE_WATCH_MARKDOWN:-none}"
else
MASCARADE_WATCH_STATUS="failed"
MASCARADE_WATCH_MARKDOWN=""
log_line ERROR "mascarade watch render failed"
fi
[[ -n "${MASCARADE_WATCH_STATUS}" ]] || MASCARADE_WATCH_STATUS="unknown"
}
refresh_daily_operator_summary() {
if bash "${PROJECT_DIR}/tools/cockpit/render_daily_operator_summary.sh" --json > "${DAILY_OPERATOR_SUMMARY_FILE}" 2>>"${LOG_FILE}"; then
DAILY_OPERATOR_SUMMARY_STATUS="$(json_get "${DAILY_OPERATOR_SUMMARY_FILE}" 'status')"
DAILY_OPERATOR_SUMMARY_MARKDOWN="$(json_get "${DAILY_OPERATOR_SUMMARY_FILE}" 'markdown_file')"
log_line INFO "daily operator summary status=${DAILY_OPERATOR_SUMMARY_STATUS:-unknown} markdown=${DAILY_OPERATOR_SUMMARY_MARKDOWN:-none}"
else
DAILY_OPERATOR_SUMMARY_STATUS="failed"
DAILY_OPERATOR_SUMMARY_MARKDOWN=""
log_line ERROR "daily operator summary render failed"
fi
[[ -n "${DAILY_OPERATOR_SUMMARY_STATUS}" ]] || DAILY_OPERATOR_SUMMARY_STATUS="unknown"
}
current_contract_status() {
local derived="ok"
local value=""
if [[ "${COMMAND_EXIT_CODE}" -ne 0 ]]; then
printf 'error'
return 0
fi
for value in "${MASCARADE_HEALTH_STATUS}" "${MASCARADE_LOGS_STATUS}" "${MASCARADE_QUEUE_STATUS}" "${MASCARADE_WATCH_STATUS}" "${DAILY_OPERATOR_SUMMARY_STATUS}"; do
case "${value:-unknown}" in
ok|ready|done|success|skipped|n/a)
;;
blocked|failed|error)
printf 'error'
return 0
;;
*)
derived="degraded"
;;
esac
done
printf '%s' "${derived}"
}
current_trust_level() {
local lane_status=""
lane_status="$(current_contract_status)"
if [[ "${lane_status}" == "ok" && "${ROUTING_STATUS}" == "ok" ]]; then
printf 'verified'
elif [[ "${ROUTING_STATUS}" == "ok" ]]; then
printf 'bounded'
else
printf 'inferred'
fi
}
refresh_mascarade_routing() {
if bash "${PROJECT_DIR}/tools/cockpit/mascarade_dispatch_mesh.sh" --action route --profile "kxkm-ops" --json > "${ROUTING_FILE}" 2>>"${LOG_FILE}"; then
ROUTING_STATUS="$(json_get "${ROUTING_FILE}" 'status')"
log_line INFO "mascarade routing status=${ROUTING_STATUS:-unknown} file=${ROUTING_FILE}"
else
ROUTING_STATUS="failed"
log_line ERROR "mascarade routing failed"
fi
[[ -n "${ROUTING_STATUS}" ]] || ROUTING_STATUS="unknown"
}
refresh_kill_life_memory() {
local lane_status=""
local trust_level=""
local next_step=""
local resume_ref=""
lane_status="$(current_contract_status)"
trust_level="$(current_trust_level)"
resume_ref="kill-life:full-operator-lane:${WORKFLOW_ID}:${COMMAND}:${TIMESTAMP}"
next_step="Review ${SUMMARY_FILE} then continue from ${COMMAND}."
if [[ "${COMMAND_EXIT_CODE}" -ne 0 ]]; then
next_step="Inspect ${LOG_FILE} and rerun bash tools/cockpit/full_operator_lane.sh ${COMMAND} --json."
fi
if bash "${PROJECT_DIR}/tools/cockpit/write_kill_life_memory_entry.sh" \
--component "full_operator_lane" \
--status "${lane_status}" \
--owner "SyncOps" \
--decision-action "full-operator-lane-${COMMAND}" \
--decision-reason "Operator lane state is projected into kill_life with Tower/KXKM mesh routing for trustworthy continuity." \
--next-step "${next_step}" \
--resume-ref "${resume_ref}" \
--trust-level "${trust_level}" \
--routing-file "${ROUTING_FILE}" \
--artifact "${SUMMARY_FILE}" \
--artifact "${LOG_FILE}" \
--artifact "${MASCARADE_WATCH_FILE}" \
--artifact "${DAILY_OPERATOR_SUMMARY_FILE}" \
--json > "${KILL_LIFE_MEMORY_FILE}" 2>>"${LOG_FILE}"; then
KILL_LIFE_MEMORY_STATUS="$(json_get "${KILL_LIFE_MEMORY_FILE}" 'status')"
log_line INFO "kill_life memory status=${KILL_LIFE_MEMORY_STATUS:-unknown} file=${KILL_LIFE_MEMORY_FILE}"
else
KILL_LIFE_MEMORY_STATUS="failed"
log_line ERROR "kill_life memory write failed"
fi
[[ -n "${KILL_LIFE_MEMORY_STATUS}" ]] || KILL_LIFE_MEMORY_STATUS="unknown"
}
refresh_product_contract_handoff() {
PRODUCT_CONTRACT_HANDOFF_FILE="${STATE_DIR}/full_operator_lane_product_contract_handoff_${TIMESTAMP}.json"
if bash "${PROJECT_DIR}/tools/cockpit/render_product_contract_handoff.sh" --no-refresh --json > "${PRODUCT_CONTRACT_HANDOFF_FILE}" 2>>"${LOG_FILE}"; then
PRODUCT_CONTRACT_HANDOFF_STATUS="$(json_get "${PRODUCT_CONTRACT_HANDOFF_FILE}" 'status')"
PRODUCT_CONTRACT_HANDOFF_MARKDOWN="$(json_get "${PRODUCT_CONTRACT_HANDOFF_FILE}" 'markdown_file')"
log_line INFO "product contract handoff status=${PRODUCT_CONTRACT_HANDOFF_STATUS:-unknown} markdown=${PRODUCT_CONTRACT_HANDOFF_MARKDOWN:-none}"
else
PRODUCT_CONTRACT_HANDOFF_STATUS="failed"
PRODUCT_CONTRACT_HANDOFF_MARKDOWN=""
log_line ERROR "product contract handoff render failed"
fi
[[ -n "${PRODUCT_CONTRACT_HANDOFF_STATUS}" ]] || PRODUCT_CONTRACT_HANDOFF_STATUS="unknown"
}
capture_post_run_logs() {
LOGS_ACTION="latest"
refresh_mascarade_logs latest
}
write_logs_summary() {
python3 - "${SUMMARY_FILE}" "${MASCARADE_LOGS_FILE}" "${LOGS_ACTION}" <<'PY'
import json
import sys
from pathlib import Path
summary_path = Path(sys.argv[1])
logs_path = Path(sys.argv[2])
logs_action = sys.argv[3]
payload = {
"status": "failed",
"logs_action": logs_action,
"source": str(logs_path),
}
if logs_path.exists():
try:
data = json.loads(logs_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
data = {"status": "failed", "error": "invalid-json", "source": str(logs_path)}
payload["status"] = data.get("status", "unknown")
payload["details"] = data
else:
payload["error"] = "missing-logs-artifact"
summary_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
PY
}
validate_workflow() {
local output_file="${STATE_DIR}/full_operator_lane_validate_${TIMESTAMP}.json"
if ! curl_json POST "${API_BASE}/workflows/${WORKFLOW_ID}/validate" "" "${output_file}"; then
write_failure_summary "${output_file}" "validate-api-unreachable" "${API_BASE}/workflows/${WORKFLOW_ID}/validate"
cp "${output_file}" "${SUMMARY_FILE}"
log_line ERROR "validate workflow=${WORKFLOW_ID} unreachable api_base=${API_BASE}; try 'bash tools/cockpit/full_operator_lane.sh status --json'"
return 1
fi
local valid
valid="$(json_get "${output_file}" 'valid')"
log_line INFO "validate workflow=${WORKFLOW_ID} valid=${valid:-unknown}"
}
start_run() {
local dry_run_flag="$1"
local output_file="${STATE_DIR}/full_operator_lane_start_${TIMESTAMP}.json"
if ! curl_json POST "${API_BASE}/workflows/${WORKFLOW_ID}/run" "{\"mode\":\"local\",\"dry_run\":${dry_run_flag}}" "${output_file}"; then
write_failure_summary "${output_file}" "run-api-unreachable" "${API_BASE}/workflows/${WORKFLOW_ID}/run"
cp "${output_file}" "${SUMMARY_FILE}"
log_line ERROR "run start unreachable workflow=${WORKFLOW_ID} dry_run=${dry_run_flag} api_base=${API_BASE}; verify local API or export CRAZY_LIFE_API_BASE"
return 1
fi
local run_id
run_id="$(json_get "${output_file}" 'run_id')"
if [[ -z "${run_id}" ]]; then
log_line ERROR "run start failed for workflow=${WORKFLOW_ID} dry_run=${dry_run_flag}"
cp "${output_file}" "${SUMMARY_FILE}"
return 1
fi
local status
status="$(json_get "${output_file}" 'status')"
cp "${output_file}" "${STATE_DIR}/full_operator_lane_run_${run_id}.json"
cp "${output_file}" "${SUMMARY_FILE}"
log_line INFO "run completed workflow=${WORKFLOW_ID} dry_run=${dry_run_flag} run_id=${run_id} status=${status:-unknown}"
}
poll_run() {
local run_id="$1"
local output_file="${STATE_DIR}/full_operator_lane_run_${run_id}.json"
local attempt="1"
while [[ "${attempt}" -le "${WAIT_ATTEMPTS}" ]]; do
if ! curl_json GET "${API_BASE}/workflows/${WORKFLOW_ID}/runs/${run_id}" "" "${output_file}"; then
write_failure_summary "${output_file}" "poll-api-unreachable" "${API_BASE}/workflows/${WORKFLOW_ID}/runs/${run_id}"
cp "${output_file}" "${SUMMARY_FILE}"
log_line ERROR "poll unreachable run_id=${run_id} attempt=${attempt} api_base=${API_BASE}; retry status once API is back"
return 1
fi
local status
status="$(json_get "${output_file}" 'status')"
log_line INFO "poll run_id=${run_id} attempt=${attempt} status=${status:-unknown}"
case "${status}" in
success|failed|no-op|cancelled|cancel_unresolved)
cp "${output_file}" "${SUMMARY_FILE}"
return 0
;;
esac
sleep "${WAIT_SECONDS}"
attempt="$((attempt + 1))"
done
cp "${output_file}" "${SUMMARY_FILE}"
}
status_command() {
if curl_json GET "${API_BASE}/workflows/${WORKFLOW_ID}" "" "${SUMMARY_FILE}"; then
log_line INFO "status workflow=${WORKFLOW_ID} summary=${SUMMARY_FILE}"
return 0
fi
write_failure_summary "${SUMMARY_FILE}" "status-api-unreachable" "${API_BASE}/workflows/${WORKFLOW_ID}"
log_line ERROR "status workflow=${WORKFLOW_ID} unreachable api_base=${API_BASE}; export CRAZY_LIFE_API_BASE or restart the local API"
return 1
}
purge_command() {
mkdir -p "${LOG_DIR}" "${STATE_DIR}"
rm -f "${LOG_DIR}"/full_operator_lane_*.log "${STATE_DIR}"/full_operator_lane_*.json "${STATE_DIR}"/full_operator_lane_mascarade_health_*.json "${STATE_DIR}"/full_operator_lane_mascarade_logs_*.json "${STATE_DIR}"/full_operator_lane_mascarade_queue_*.json "${STATE_DIR}"/full_operator_lane_mascarade_watch_*.json "${STATE_DIR}"/full_operator_lane_routing_*.json "${STATE_DIR}"/full_operator_lane_kill_life_memory_*.json "${STATE_DIR}"/live_provider_result.json
printf '{"status":"done","purged":["artifacts/cockpit/full_operator_lane_*.log","artifacts/operator_lane/full_operator_lane_*.json","artifacts/operator_lane/full_operator_lane_mascarade_health_*.json","artifacts/operator_lane/full_operator_lane_mascarade_logs_*.json","artifacts/operator_lane/full_operator_lane_mascarade_queue_*.json","artifacts/operator_lane/full_operator_lane_mascarade_watch_*.json","artifacts/operator_lane/full_operator_lane_routing_*.json","artifacts/operator_lane/full_operator_lane_kill_life_memory_*.json","artifacts/operator_lane/live_provider_result.json"]}\n' > "${SUMMARY_FILE}"
MASCARADE_HEALTH_STATUS="skipped"
MASCARADE_QUEUE_STATUS="skipped"
MASCARADE_QUEUE_MARKDOWN=""
MASCARADE_WATCH_STATUS="skipped"
MASCARADE_WATCH_MARKDOWN=""
refresh_mascarade_logs purge
log_line INFO "purged full operator lane artifacts"
}
while [[ $# -gt 0 ]]; do
case "$1" in
status|dry-run|live|all|logs|purge)
COMMAND="$1"
;;
--json)
JSON_OUTPUT=1
;;
--logs-action)
if [[ $# -lt 2 ]]; then
usage >&2
exit 2
fi
LOGS_ACTION="$2"
shift
;;
-h|--help)
usage
exit 0
;;
*)
usage >&2
exit 2
;;
esac
shift
done
if [[ ! "${LOGS_ACTION}" =~ ^(summary|latest|list|purge)$ ]]; then
echo "[error] --logs-action requires summary|latest|list|purge" >&2
exit 2
fi
case "${COMMAND}" in
status)
refresh_mascarade_health
refresh_mascarade_logs summary
refresh_mascarade_queue
refresh_daily_operator_summary
refresh_mascarade_watch
status_command || COMMAND_EXIT_CODE=1
;;
dry-run)
refresh_mascarade_health
refresh_mascarade_logs summary
refresh_mascarade_queue
refresh_daily_operator_summary
refresh_mascarade_watch
validate_workflow || COMMAND_EXIT_CODE=1
if [[ "${COMMAND_EXIT_CODE}" -eq 0 ]]; then
start_run true || COMMAND_EXIT_CODE=1
fi
capture_post_run_logs
refresh_mascarade_queue
refresh_daily_operator_summary
refresh_mascarade_watch
;;
live)
refresh_mascarade_health
refresh_mascarade_logs summary
refresh_mascarade_queue
refresh_daily_operator_summary
refresh_mascarade_watch
validate_workflow || COMMAND_EXIT_CODE=1
if [[ "${COMMAND_EXIT_CODE}" -eq 0 ]]; then
start_run false || COMMAND_EXIT_CODE=1
fi
capture_post_run_logs
refresh_mascarade_queue
refresh_daily_operator_summary
refresh_mascarade_watch
;;
all)
refresh_mascarade_health
refresh_mascarade_logs summary
refresh_mascarade_queue
refresh_daily_operator_summary
refresh_mascarade_watch
validate_workflow || COMMAND_EXIT_CODE=1
if [[ "${COMMAND_EXIT_CODE}" -eq 0 ]]; then
start_run true || COMMAND_EXIT_CODE=1
fi
if [[ "${COMMAND_EXIT_CODE}" -eq 0 ]]; then
start_run false || COMMAND_EXIT_CODE=1
fi
capture_post_run_logs
refresh_mascarade_queue
refresh_daily_operator_summary
refresh_mascarade_watch
;;
logs)
refresh_mascarade_health
refresh_mascarade_logs "${LOGS_ACTION}"
refresh_mascarade_queue
write_logs_summary
refresh_daily_operator_summary
refresh_mascarade_watch
if [[ "${MASCARADE_LOGS_STATUS}" == "failed" || "${MASCARADE_LOGS_STATUS}" == "error" ]]; then
COMMAND_EXIT_CODE=1
fi
;;
purge)
purge_command
;;
esac
refresh_mascarade_routing
refresh_kill_life_memory
refresh_product_contract_handoff
if [[ "${JSON_OUTPUT}" -eq 1 && -f "${SUMMARY_FILE}" ]]; then
emit_contract_json
fi
if [[ "${COMMAND_EXIT_CODE}" -ne 0 ]]; then
exit "${COMMAND_EXIT_CODE}"
fi
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
TIMESTAMP="$(date +"%Y%m%d_%H%M%S")"
LOG_DIR="${ROOT_DIR}/artifacts/cockpit"
STATE_DIR="${ROOT_DIR}/artifacts/operator_lane"
LOG_FILE="${LOG_DIR}/full_operator_lane_sync_${TIMESTAMP}.log"
SUMMARY_FILE="${STATE_DIR}/full_operator_lane_sync_${TIMESTAMP}.json"
JSON_MODE="no"
MODE="staged"
LOCAL_KILL_LIFE_MIRROR="${LOCAL_KILL_LIFE_MIRROR:-/Users/electron/Documents/Lelectron_rare/Github_Repos/Perso/Kill_LIFE-main}"
LOCAL_MASCARADE_MIRROR="${LOCAL_MASCARADE_MIRROR:-/Users/electron/Documents/Lelectron_rare/Github_Repos/Perso/mascarade-main}"
LOCAL_CRAZY_LIFE_MIRROR="${LOCAL_CRAZY_LIFE_MIRROR:-/Users/electron/Documents/Lelectron_rare/Github_Repos/Perso/crazy_life-main}"
KILL_LIFE_FILES=(
"tools/cockpit/full_operator_lane.sh"
"tools/cockpit/full_operator_lane_sync.sh"
"tools/cockpit/README.md"
"tools/ops/operator_live_provider_smoke.py"
"tools/ops/operator_live_provider_smoke.js"
"workflows/embedded-operator-live.json"
"docs/FULL_OPERATOR_LANE_2026-03-20.md"
"docs/PROVIDER_RUNTIME_COMPAT_2026-03-20.md"
"docs/MACHINE_SYNC_STATUS_2026-03-20.md"
"docs/plans/12_plan_gestion_des_agents.md"
"docs/plans/19_todo_mesh_tri_repo.md"
"docs/index.md"
"README.md"
"specs/03_plan.md"
"specs/04_tasks.md"
)
MASCARADE_FILES=(
"api/src/lib/killlife.ts"
"docs/FULL_OPERATOR_LANE_2026-03-20.md"
"docs/TODO_MESH_TRI_REPO_2026-03-20.md"
"README.md"
)
CRAZY_LIFE_FILES=(
"api/src/lib/killlife.ts"
"docs/FULL_OPERATOR_LANE_2026-03-20.md"
"docs/TODO_MESH_TRI_REPO_2026-03-20.md"
"README.md"
)
REMOTE_TARGETS=(
"clems@192.168.0.120|/home/clems/Kill_LIFE-main|/home/clems/mascarade-main|/home/clems/crazy_life-main"
"root@192.168.0.119|/root/Kill_LIFE-main|/root/mascarade-main|/root/crazy_life-main"
"kxkm@kxkm-ai|/home/kxkm/Kill_LIFE-main|/home/kxkm/mascarade-main|/home/kxkm/crazy_life-main"
"cils@100.126.225.111|/Users/cils/Kill_LIFE-main|/Users/cils/mascarade-main|/Users/cils/crazy_life-main"
)
usage() {
cat <<'EOF'
Usage: bash tools/cockpit/full_operator_lane_sync.sh [--json] [--mode staged|clems-live]
EOF
}
log_line() {
local level="$1"
local message="$2"
mkdir -p "${LOG_DIR}" "${STATE_DIR}"
local line
line="${level} $(date +"%Y-%m-%d %H:%M:%S %z") ${message}"
printf '%s\n' "${line}" >> "${LOG_FILE}"
if [[ "${JSON_MODE}" != "yes" ]]; then
printf '%s\n' "${line}"
fi
}
copy_local_file() {
local src="$1"
local dest="$2"
mkdir -p "$(dirname "${dest}")"
cp "${src}" "${dest}"
}
sync_local_repo() {
local src_root="$1"
local dest_root="$2"
shift 2
local rel
for rel in "$@"; do
copy_local_file "${src_root}/${rel}" "${dest_root}/${rel}"
done
}
sync_remote_repo() {
local src_root="$1"
local target="$2"
local dest_root="$3"
shift 3
tar -cf - -C "${src_root}" "$@" | ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new "${target}" \
"mkdir -p \"${dest_root}\" && cd \"${dest_root}\" && tar -xf -"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--json)
JSON_MODE="yes"
shift
;;
--mode)
MODE="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
usage
exit 2
;;
esac
done
if [[ "${MODE}" != "staged" && "${MODE}" != "clems-live" ]]; then
usage
exit 2
fi
mkdir -p "${LOG_DIR}" "${STATE_DIR}"
log_line INFO "Starting full operator lane sync mode=${MODE}"
sync_local_repo "${ROOT_DIR}" "${LOCAL_KILL_LIFE_MIRROR}" "${KILL_LIFE_FILES[@]}"
log_line INFO "Updated local Kill_LIFE-main mirror"
for entry in "${REMOTE_TARGETS[@]}"; do
IFS='|' read -r target kill_life_root mascarade_root crazy_life_root <<< "${entry}"
log_line INFO "Syncing staged lanes on ${target}"
sync_remote_repo "${ROOT_DIR}" "${target}" "${kill_life_root}" "${KILL_LIFE_FILES[@]}"
sync_remote_repo "${LOCAL_MASCARADE_MIRROR}" "${target}" "${mascarade_root}" "${MASCARADE_FILES[@]}"
sync_remote_repo "${LOCAL_CRAZY_LIFE_MIRROR}" "${target}" "${crazy_life_root}" "${CRAZY_LIFE_FILES[@]}"
log_line INFO "Synced staged lanes on ${target}"
done
if [[ "${MODE}" == "clems-live" ]]; then
sync_remote_repo "${ROOT_DIR}" "clems@192.168.0.120" "/home/clems/Kill_LIFE" \
"tools/ops/operator_live_provider_smoke.py" \
"tools/ops/operator_live_provider_smoke.js" \
"workflows/embedded-operator-live.json"
sync_remote_repo "${LOCAL_MASCARADE_MIRROR}" "clems@192.168.0.120" "/home/clems/mascarade" \
"api/src/lib/killlife.ts"
log_line INFO "Synced clems live roots"
fi
cat > "${SUMMARY_FILE}" <<EOF
{
"generated_at": "$(date +"%Y-%m-%d %H:%M:%S %z")",
"status": "done",
"mode": "${MODE}",
"targets": ${#REMOTE_TARGETS[@]},
"log_file": "${LOG_FILE}"
}
EOF
if [[ "${JSON_MODE}" == "yes" ]]; then
cat "${SUMMARY_FILE}"
fi
+309
View File
@@ -0,0 +1,309 @@
#!/usr/bin/env bash
set -euo pipefail
# ──────────────────────────────────────────────────────────────────────────
# infra_container_health.sh — Infrastructure Container Health Check & Fix
#
# Vérifie l'état de tous les containers Docker sur la VM photon-docker
# et propose des actions correctives pour les containers down.
#
# Usage:
# bash infra_container_health.sh --action status
# bash infra_container_health.sh --action fix --target metabase
# bash infra_container_health.sh --action fix-all --yes
# bash infra_container_health.sh --action web-check
# bash infra_container_health.sh --json
#
# Contract: cockpit-v1
# Owner: Sentinelle + PM-Mesh
# Date: 2026-03-21
# ──────────────────────────────────────────────────────────────────────────
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ARTIFACTS_DIR="${ROOT_DIR}/artifacts/infra_container_health"
mkdir -p "${ARTIFACTS_DIR}"
STAMP="$(date +%Y%m%d_%H%M%S)"
LOG_FILE="${ARTIFACTS_DIR}/health_${STAMP}.log"
ACTION=""
TARGET=""
JSON_MODE=0
VERBOSE=0
YES=0
# VM Configuration
VM_HOST="${VM_HOST:-192.168.0.119}"
VM_USER="${VM_USER:-clement}"
SSH_OPTS="-o ConnectTimeout=10 -o StrictHostKeyChecking=no -o BatchMode=yes"
# Known services and their expected container names/web endpoints
declare -A SERVICE_URLS=(
["mascarade"]="https://mascarade.saillant.cc"
["langfuse"]="https://langfuse.saillant.cc"
["grafana"]="https://grafana.saillant.cc"
["authentik"]="https://auth.saillant.cc"
["outline"]="https://outline.saillant.cc"
["n8n"]="https://n8n.saillant.cc"
["gitea"]="https://gitea.saillant.cc"
["uptime-kuma"]="https://uptime.saillant.cc"
["portainer"]="https://portainer.saillant.cc"
["metabase"]="https://metabase.saillant.cc"
["listmonk"]="https://listmonk.saillant.cc"
["changedetection"]="https://changedetection.saillant.cc"
["bookmarks"]="https://bookmarks.saillant.cc"
["dify"]="https://dify.saillant.cc"
)
# Couleurs
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
usage() {
cat <<'EOF'
Usage: infra_container_health.sh --action <status|fix|fix-all|web-check|docker-status|restart> [options]
Actions:
status Full health report (web + docker)
web-check Check web endpoints only (no SSH required)
docker-status Check Docker containers via SSH
fix Restart a specific container (requires --target)
fix-all Restart all down containers (requires --yes)
restart Restart a specific container (alias for fix)
Options:
--action <name> Action to run
--target <name> Container name for fix/restart
--json Emit JSON report
--yes Confirm destructive actions
--verbose Show detailed output
--help Show this help
EOF
}
log_info() { printf "${CYAN}[INFO]${NC} %s\n" "$*"; }
log_ok() { printf "${GREEN}[ OK ]${NC} %s\n" "$*"; }
log_warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$*"; }
log_err() { printf "${RED}[FAIL]${NC} %s\n" "$*"; }
# ── Web Health Check ──────────────────────────────────────────────────────
check_web_endpoint() {
local name="$1"
local url="$2"
local http_code
http_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -L "${url}" 2>/dev/null) || http_code="000"
local status="down"
if [[ "${http_code}" =~ ^(200|301|302|303|307|401|403)$ ]]; then
status="up"
fi
echo "${name}|${url}|${http_code}|${status}"
}
action_web_check() {
log_info "Checking web endpoints..."
local up=0 down=0 results=()
for name in $(echo "${!SERVICE_URLS[@]}" | tr ' ' '\n' | sort); do
local url="${SERVICE_URLS[$name]}"
local result
result=$(check_web_endpoint "${name}" "${url}")
results+=("${result}")
local http_code status
http_code=$(echo "${result}" | cut -d'|' -f3)
status=$(echo "${result}" | cut -d'|' -f4)
if [[ "${status}" == "up" ]]; then
log_ok "${name} — HTTP ${http_code} (${url})"
((up++))
else
log_err "${name} — HTTP ${http_code} (${url})"
((down++))
fi
done
local total=$((up + down))
echo ""
printf "═══ Web Health: %d/%d up " "${up}" "${total}"
if [[ "${down}" -gt 0 ]]; then
printf "(${RED}%d down${NC})" "${down}"
else
printf "(${GREEN}all healthy${NC})"
fi
echo " ═══"
if [[ "${JSON_MODE}" -eq 1 ]]; then
python3 -c "
import json
results = []
for r in '''$(printf '%s\n' "${results[@]}")'''.strip().split('\n'):
parts = r.split('|')
if len(parts) == 4:
results.append({'name': parts[0], 'url': parts[1], 'http_code': int(parts[2]) if parts[2].isdigit() else 0, 'status': parts[3]})
print(json.dumps({
'contract': 'cockpit-v1',
'tool': 'infra_container_health',
'action': 'web-check',
'timestamp': '${STAMP}',
'total': ${total},
'up': ${up},
'down': ${down},
'services': results
}, indent=2))
"
fi
}
# ── Docker Status via SSH ─────────────────────────────────────────────────
action_docker_status() {
log_info "Checking Docker containers on ${VM_HOST}..."
local ssh_result
if ! ssh_result=$(ssh ${SSH_OPTS} "${VM_USER}@${VM_HOST}" \
"docker ps -a --format '{{.Names}}|{{.Status}}|{{.Image}}|{{.Ports}}'" 2>/dev/null); then
log_err "SSH connection to ${VM_HOST} failed"
return 1
fi
local running=0 stopped=0 total=0
echo ""
printf "%-30s %-15s %s\n" "CONTAINER" "STATE" "IMAGE"
printf "%s\n" "$(printf '─%.0s' {1..80})"
while IFS='|' read -r name status image ports; do
[[ -z "${name}" ]] && continue
((total++))
local state="stopped"
if [[ "${status}" == *"Up"* ]]; then
state="running"
((running++))
printf "${GREEN}%-30s${NC} %-15s %s\n" "${name}" "${state}" "${image}"
else
((stopped++))
printf "${RED}%-30s${NC} %-15s %s\n" "${name}" "${state}" "${image}"
fi
done <<< "${ssh_result}"
echo ""
printf "═══ Docker: %d/%d running " "${running}" "${total}"
if [[ "${stopped}" -gt 0 ]]; then
printf "(${RED}%d stopped${NC})" "${stopped}"
else
printf "(${GREEN}all running${NC})"
fi
echo " ═══"
}
# ── Fix / Restart ─────────────────────────────────────────────────────────
action_fix() {
if [[ -z "${TARGET}" ]]; then
log_err "Missing --target <container_name>"
return 1
fi
log_info "Attempting to restart container: ${TARGET}"
# Try docker compose first, then docker restart
local compose_result
if ssh ${SSH_OPTS} "${VM_USER}@${VM_HOST}" \
"cd /opt/docker && docker compose up -d ${TARGET}" 2>/dev/null; then
log_ok "Container ${TARGET} restarted via docker compose"
elif ssh ${SSH_OPTS} "${VM_USER}@${VM_HOST}" \
"docker restart ${TARGET}" 2>/dev/null; then
log_ok "Container ${TARGET} restarted via docker restart"
else
log_err "Failed to restart ${TARGET}"
return 1
fi
# Verify
sleep 3
local status
status=$(ssh ${SSH_OPTS} "${VM_USER}@${VM_HOST}" \
"docker inspect -f '{{.State.Status}}' ${TARGET}" 2>/dev/null) || status="unknown"
log_info "Post-restart status: ${status}"
}
action_fix_all() {
if [[ "${YES}" -ne 1 ]]; then
log_err "Requires --yes flag to restart all down containers"
return 1
fi
log_info "Finding stopped containers..."
local stopped
stopped=$(ssh ${SSH_OPTS} "${VM_USER}@${VM_HOST}" \
"docker ps -a --filter 'status=exited' --filter 'status=created' --format '{{.Names}}'" 2>/dev/null) || {
log_err "SSH connection failed"
return 1
}
if [[ -z "${stopped}" ]]; then
log_ok "No stopped containers found"
return 0
fi
local count=0
while IFS= read -r name; do
[[ -z "${name}" ]] && continue
log_info "Restarting: ${name}"
if ssh ${SSH_OPTS} "${VM_USER}@${VM_HOST}" "docker start ${name}" 2>/dev/null; then
log_ok "Started: ${name}"
((count++))
else
log_err "Failed to start: ${name}"
fi
done <<< "${stopped}"
log_info "Restarted ${count} containers"
}
# ── Full Status ───────────────────────────────────────────────────────────
action_status() {
echo "╔══════════════════════════════════════════════════╗"
echo "║ Infrastructure Health Report — ${STAMP}"
echo "╚══════════════════════════════════════════════════╝"
echo ""
action_web_check
echo ""
action_docker_status
}
# ── Main ──────────────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--action) ACTION="${2:-}"; shift 2 ;;
--target) TARGET="${2:-}"; shift 2 ;;
--json) JSON_MODE=1; shift ;;
--yes) YES=1; shift ;;
--verbose) VERBOSE=1; shift ;;
--help) usage; exit 0 ;;
*) printf 'Unknown: %s\n' "$1" >&2; usage >&2; exit 2 ;;
esac
done
[[ -z "${ACTION}" ]] && { usage >&2; exit 2; }
exec > >(tee -a "${LOG_FILE}") 2>&1
printf '[infra-container-health] action=%s timestamp=%s\n' "${ACTION}" "${STAMP}"
case "${ACTION}" in
status) action_status ;;
web-check) action_web_check ;;
docker-status) action_docker_status ;;
fix|restart) action_fix ;;
fix-all) action_fix_all ;;
*)
printf 'Unknown action: %s\n' "${ACTION}" >&2
usage >&2
exit 2
;;
esac
+196
View File
@@ -0,0 +1,196 @@
#!/bin/bash
# ============================================================================
# integration_health_tui.sh — Health check complet de l'écosystème saillant.cc
# Contrat: cockpit-v1
# Lot: 23 — Intégration Mistral Agents
# Date: 2026-03-21
# ============================================================================
set -euo pipefail
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
VM_HOST="${VM_HOST:-192.168.0.119}"
VM_USER="${VM_USER:-clement}"
TIMEOUT=5
# --- Services catalogue ---
declare -A SERVICES=(
["Authentik SSO"]="https://auth.saillant.cc"
["Mascarade API"]="https://mascarade.saillant.cc/health"
["Langfuse"]="https://langfuse.saillant.cc"
["Grafana"]="https://grafana.saillant.cc"
["Prometheus"]="https://prometheus.saillant.cc"
["Outline Wiki"]="https://wiki.saillant.cc"
["n8n Workflows"]="https://n8n.saillant.cc"
["Excalidraw"]="https://draw.saillant.cc"
["Dify AI"]="https://dify.saillant.cc"
["Gitea"]="https://git.saillant.cc"
["Portainer"]="https://portainer.saillant.cc"
["Uptime Kuma"]="https://uptime.saillant.cc"
["Homepage"]="https://home.saillant.cc"
["Audiobookshelf"]="https://audio.saillant.cc"
["Immich"]="https://photos.saillant.cc"
["Vaultwarden"]="https://vault.saillant.cc"
["Paperless"]="https://docs.saillant.cc"
["Metabase"]="https://metabase.saillant.cc"
["Listmonk"]="https://listmonk.saillant.cc"
["Changedetection"]="https://changes.saillant.cc"
["Bookmarks"]="https://bookmarks.saillant.cc"
)
# --- Fonctions ---
check_service() {
local name="$1" url="$2"
local http_code
http_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$TIMEOUT" \
-L "$url" 2>/dev/null || echo "000")
case "$http_code" in
200|301|302|303|307|308)
echo -e " ${GREEN}${NC} ${name}: ${GREEN}OK${NC} (${http_code})"
return 0
;;
401|403)
echo -e " ${GREEN}${NC} ${name}: ${GREEN}AUTH-OK${NC} (${http_code} — SSO protégé)"
return 0
;;
502|503|504)
echo -e " ${RED}${NC} ${name}: ${RED}DOWN${NC} (${http_code})"
return 1
;;
000)
echo -e " ${RED}${NC} ${name}: ${RED}TIMEOUT${NC}"
return 1
;;
*)
echo -e " ${YELLOW}?${NC} ${name}: ${YELLOW}HTTP ${http_code}${NC}"
return 1
;;
esac
}
check_docker_containers() {
echo -e "${BOLD}[Docker Containers — ${VM_HOST}]${NC}"
local output
output=$(ssh -o ConnectTimeout="$TIMEOUT" -o BatchMode=yes "${VM_USER}@${VM_HOST}" \
"docker ps -a --format '{{.Names}}\t{{.Status}}\t{{.Ports}}' 2>/dev/null" 2>/dev/null) || {
echo -e " ${RED}✗ SSH connection failed to ${VM_HOST}${NC}"
return 1
}
local running=0 stopped=0 total=0
while IFS=$'\t' read -r name status ports; do
((total++))
if echo "$status" | grep -q "^Up"; then
((running++))
else
((stopped++))
echo -e " ${RED}${NC} ${name}: ${RED}${status}${NC}"
fi
done <<< "$output"
echo -e " ${GREEN}${NC} Running: ${GREEN}${running}${NC}/${total} | Stopped: ${RED}${stopped}${NC}"
return 0
}
check_mistral_agents() {
local api_key="${MISTRAL_API_KEY:-}"
echo -e "${BOLD}[Mistral Agents]${NC}"
if [ -z "$api_key" ]; then
echo -e " ${YELLOW}${NC} MISTRAL_API_KEY not set — skip"
return 0
fi
local agents=("sentinelle-ops-v1" "tower-commercial-v1" "forge-finetune-v1" "devstral-code-v1")
local names=("Sentinelle" "Tower" "Forge" "Devstral")
for i in "${!agents[@]}"; do
local http_code
http_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$TIMEOUT" \
-H "Authorization: Bearer $api_key" \
"https://api.mistral.ai/v1/agents/${agents[$i]}" 2>/dev/null || echo "000")
case "$http_code" in
200) echo -e " ${GREEN}${NC} ${names[$i]}: ${GREEN}Active${NC}" ;;
404) echo -e " ${YELLOW}${NC} ${names[$i]}: ${YELLOW}Not deployed${NC}" ;;
*) echo -e " ${RED}${NC} ${names[$i]}: ${RED}Error (${http_code})${NC}" ;;
esac
done
}
# --- Action principale ---
action_full_health() {
local ok=0 fail=0
echo -e "${BOLD}${CYAN}╔══════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${CYAN}║ Écosystème saillant.cc — Health Check ║${NC}"
echo -e "${BOLD}${CYAN}$(date '+%Y-%m-%d %H:%M:%S')${NC}"
echo -e "${BOLD}${CYAN}╚══════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${BOLD}[Web Services]${NC}"
for name in $(echo "${!SERVICES[@]}" | tr ' ' '\n' | sort); do
if check_service "$name" "${SERVICES[$name]}"; then
((ok++))
else
((fail++))
fi
done
echo ""
check_docker_containers
echo ""
check_mistral_agents
echo ""
echo -e "${BOLD}[Résumé]${NC}"
local total=$((ok + fail))
local pct=0
[ "$total" -gt 0 ] && pct=$((ok * 100 / total))
if [ "$fail" -eq 0 ]; then
echo -e " ${GREEN}✓ ALL HEALTHY${NC}${ok}/${total} services OK (${pct}%)"
elif [ "$fail" -le 3 ]; then
echo -e " ${YELLOW}⚠ DEGRADED${NC}${ok}/${total} services OK (${pct}%) | ${fail} down"
else
echo -e " ${RED}✗ CRITICAL${NC}${ok}/${total} services OK (${pct}%) | ${fail} down"
fi
echo ""
# JSON cockpit-v1 output
cat <<EOF
{
"contract_version": "cockpit-v1",
"component": "ecosystem-health",
"action": "full-check",
"status": "$([ "$fail" -eq 0 ] && echo "healthy" || ([ "$fail" -le 3 ] && echo "degraded" || echo "critical"))",
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"services_ok": $ok,
"services_fail": $fail,
"services_total": $total,
"uptime_pct": $pct
}
EOF
}
# --- Main ---
case "${1:-}" in
--json)
action_full_health 2>/dev/null | grep -A 999 '{'
;;
--help|-h)
echo "Usage: $0 [--json] [--help]"
echo ""
echo " (default) Full health check with colored output"
echo " --json Output cockpit-v1 JSON only"
;;
*)
action_full_health
;;
esac
+256
View File
@@ -0,0 +1,256 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
source "${ROOT_DIR}/tools/cockpit/json_contract.sh"
AUDIT_DOC="${ROOT_DIR}/docs/KILL_LIFE_CONSOLIDATION_AUDIT_2026-03-21.md"
SPEC_DOC="${ROOT_DIR}/specs/agentic_intelligence_integration_spec.md"
FEATURE_DOC="${ROOT_DIR}/docs/AGENTIC_INTELLIGENCE_FEATURE_MAP_2026-03-21.md"
PLAN_DOC="${ROOT_DIR}/docs/plans/22_plan_integration_intelligence_agentique.md"
TODO_DOC="${ROOT_DIR}/docs/plans/22_todo_integration_intelligence_agentique.md"
RESEARCH_DOC="${ROOT_DIR}/docs/WEB_RESEARCH_OPEN_SOURCE_2026-03-20.md"
OWNERS_DOC="${ROOT_DIR}/docs/plans/12_plan_gestion_des_agents.md"
GLOBAL_TASKS_DOC="${ROOT_DIR}/specs/04_tasks.md"
LOG_DIR="${ROOT_DIR}/artifacts/cockpit/intelligence_program_tui"
mkdir -p "${LOG_DIR}"
ACTION=""
JSON=0
RETENTION_DAYS=7
LINES=120
LOG_FILE="${LOG_DIR}/intelligence_program_tui_$(date '+%Y%m%d_%H%M%S').log"
usage() {
cat <<'EOF'
Usage: bash tools/cockpit/intelligence_program_tui.sh [options]
Options:
--action <status|audit|feature-map|spec|plan|todo|research|owners|logs-summary|logs-list|logs-latest|purge-logs>
--json
--days <N>
--lines <N>
-h, --help
EOF
}
have_tty() {
[[ -t 0 && -t 1 ]]
}
choose_action_interactive() {
if command -v gum >/dev/null 2>&1 && have_tty; then
gum choose \
status \
audit \
feature-map \
spec \
plan \
todo \
research \
owners \
logs-summary \
logs-list \
logs-latest \
purge-logs
return 0
fi
return 1
}
log_line() {
local level="$1"
shift
local msg="$*"
printf '[%s] [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S %z')" "${level}" "${msg}" | tee -a "${LOG_FILE}" >&2
}
count_matching_lines() {
local pattern="$1"
local file="$2"
rg -c "${pattern}" "${file}" 2>/dev/null || printf '0\n'
}
show_file() {
local file="$1"
if [[ ! -f "${file}" ]]; then
printf 'Missing file: %s\n' "${file}" >&2
return 1
fi
sed -n "1,${LINES}p" "${file}"
}
emit_status() {
local status="ok"
local contract_status="ok"
local degraded_reasons=()
local next_steps=(
"bash tools/cockpit/intelligence_program_tui.sh --action plan"
"bash tools/cockpit/intelligence_program_tui.sh --action todo"
"bash tools/cockpit/intelligence_program_tui.sh --action research"
)
local open_todo_count completed_todo_count global_open_tasks
open_todo_count="$(count_matching_lines '^- \[ \]' "${TODO_DOC}")"
completed_todo_count="$(count_matching_lines '^- \[x\]' "${TODO_DOC}")"
global_open_tasks="$(count_matching_lines '^- \[ \]' "${GLOBAL_TASKS_DOC}")"
for required_file in "${AUDIT_DOC}" "${SPEC_DOC}" "${FEATURE_DOC}" "${PLAN_DOC}" "${TODO_DOC}" "${RESEARCH_DOC}" "${OWNERS_DOC}"; do
if [[ ! -f "${required_file}" ]]; then
status="degraded"
degraded_reasons+=("missing:$(basename "${required_file}")")
fi
done
contract_status="$(json_contract_map_status "${status}")"
if [[ "${JSON}" -eq 1 ]]; then
printf '{\n'
printf ' "contract_version": "cockpit-v1",\n'
printf ' "component": "intelligence_program_tui",\n'
printf ' "contract_status": "%s",\n' "${contract_status}"
printf ' "status": "%s",\n' "${status}"
printf ' "action": "status",\n'
printf ' "artifacts": %s,\n' "$(json_contract_array_from_args "${LOG_FILE}" "${AUDIT_DOC}" "${SPEC_DOC}" "${FEATURE_DOC}" "${PLAN_DOC}" "${TODO_DOC}")"
printf ' "degraded_reasons": %s,\n' "$(json_contract_array_from_args "${degraded_reasons[@]}")"
printf ' "next_steps": %s,\n' "$(json_contract_array_from_args "${next_steps[@]}")"
printf ' "audit_doc": "%s",\n' "${AUDIT_DOC}"
printf ' "spec_doc": "%s",\n' "${SPEC_DOC}"
printf ' "feature_map_doc": "%s",\n' "${FEATURE_DOC}"
printf ' "plan_doc": "%s",\n' "${PLAN_DOC}"
printf ' "todo_doc": "%s",\n' "${TODO_DOC}"
printf ' "research_doc": "%s",\n' "${RESEARCH_DOC}"
printf ' "owners_doc": "%s",\n' "${OWNERS_DOC}"
printf ' "open_todo_count": %s,\n' "${open_todo_count}"
printf ' "completed_todo_count": %s,\n' "${completed_todo_count}"
printf ' "global_open_tasks": %s,\n' "${global_open_tasks}"
printf ' "log_file": "%s"\n' "${LOG_FILE}"
printf '}\n'
else
printf 'Intelligence Program Status\n\n'
printf 'status: %s\n' "${status}"
printf 'audit: %s\n' "${AUDIT_DOC}"
printf 'spec: %s\n' "${SPEC_DOC}"
printf 'feature map: %s\n' "${FEATURE_DOC}"
printf 'plan: %s\n' "${PLAN_DOC}"
printf 'todo: %s\n' "${TODO_DOC}"
printf 'research: %s\n' "${RESEARCH_DOC}"
printf 'owners: %s\n' "${OWNERS_DOC}"
printf 'todo open/completed: %s/%s\n' "${open_todo_count}" "${completed_todo_count}"
printf 'global open tasks: %s\n' "${global_open_tasks}"
printf 'log: %s\n' "${LOG_FILE}"
fi
}
list_logs() {
find "${LOG_DIR}" -type f -name 'intelligence_program_tui_*.log' | sort
}
latest_log() {
find "${LOG_DIR}" -type f -name 'intelligence_program_tui_*.log' | sort | tail -n 1
}
purge_logs() {
find "${LOG_DIR}" -type f -name 'intelligence_program_tui_*.log' -mtime +"${RETENTION_DAYS}" -delete
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--json)
JSON=1
shift
;;
--days)
RETENTION_DAYS="${2:-7}"
shift 2
;;
--lines)
LINES="${2:-120}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
if [[ -z "${ACTION}" ]]; then
if ACTION="$(choose_action_interactive)"; then
:
else
ACTION="status"
fi
fi
case "${ACTION}" in
status)
log_line "INFO" "status"
emit_status
;;
audit)
log_line "INFO" "audit"
show_file "${AUDIT_DOC}"
;;
feature-map)
log_line "INFO" "feature-map"
show_file "${FEATURE_DOC}"
;;
spec)
log_line "INFO" "spec"
show_file "${SPEC_DOC}"
;;
plan)
log_line "INFO" "plan"
show_file "${PLAN_DOC}"
;;
todo)
log_line "INFO" "todo"
show_file "${TODO_DOC}"
;;
research)
log_line "INFO" "research"
show_file "${RESEARCH_DOC}"
;;
owners)
log_line "INFO" "owners"
show_file "${OWNERS_DOC}"
;;
logs-summary)
log_line "INFO" "logs-summary"
printf 'log_dir=%s\n' "${LOG_DIR}"
printf 'latest=%s\n' "$(latest_log)"
printf 'count=%s\n' "$(find "${LOG_DIR}" -type f -name 'intelligence_program_tui_*.log' | wc -l | tr -d ' ')"
;;
logs-list)
log_line "INFO" "logs-list"
list_logs
;;
logs-latest)
log_line "INFO" "logs-latest"
latest="$(latest_log)"
if [[ -n "${latest}" ]]; then
cat "${latest}"
fi
;;
purge-logs)
log_line "INFO" "purge-logs days=${RETENTION_DAYS}"
purge_logs
;;
*)
printf 'Unknown action: %s\n' "${ACTION}" >&2
usage >&2
exit 2
;;
esac
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# Compatibility wrapper kept for historical runbooks and aliases.
exec bash "${SCRIPT_DIR}/intelligence_tui.sh" "$@"
+1669
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
json_contract_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
json_contract_append_string() {
local current="$1"
local value="$2"
local escaped=""
escaped="$(json_contract_escape "${value}")"
if [[ -z "${current}" ]]; then
printf '"%s"' "${escaped}"
else
printf '%s,"%s"' "${current}" "${escaped}"
fi
}
json_contract_array_from_args() {
local payload=""
local value=""
for value in "$@"; do
[[ -n "${value}" ]] || continue
payload="$(json_contract_append_string "${payload}" "${value}")"
done
printf '[%s]' "${payload}"
}
json_contract_map_status() {
case "${1:-unknown}" in
ok|done|ready|success)
printf 'ok'
;;
degraded|warn|warning|no-op|pending|running|skipped|unknown)
printf 'degraded'
;;
blocked|ko|error|fail|failed|cancelled|cancel_unresolved)
printf 'error'
;;
*)
printf 'degraded'
;;
esac
}
+266
View File
@@ -0,0 +1,266 @@
#!/usr/bin/env bash
set -euo pipefail
# kill_life_mistral_governance_sync.sh
# Sync Kill_LIFE governance-only Mistral key to per-user secret files on mesh machines.
# Contract: cockpit-v1
# Date: 2026-03-22
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ARTIFACTS_DIR="${ROOT_DIR}/artifacts/cockpit/kill_life_mistral_governance_sync"
mkdir -p "${ARTIFACTS_DIR}"
ACTION="sync"
SECRET_FILE="${SECRET_FILE:-${HOME}/.kill-life/mistral.env}"
HOST_LABELS=(
"local"
"clems"
"root"
"kxkm"
"cils"
)
HOST_TARGETS=(
"local"
"clems@192.168.0.120"
"root@192.168.0.119"
"kxkm@kxkm-ai"
"cils@100.126.225.111"
)
HOST_SECRET_FILES=(
"${HOME}/.kill-life/mistral.env"
"/home/clems/.kill-life/mistral.env"
"/root/.kill-life/mistral.env"
"/home/kxkm/.kill-life/mistral.env"
"/Users/cils/.kill-life/mistral.env"
)
usage() {
cat <<'EOF'
Usage: kill_life_mistral_governance_sync.sh [--action sync|status] [--help]
Actions:
sync Write/update MISTRAL_GOVERNANCE_API_KEY on all configured machines
status Report presence of the secret file on all configured machines
Env vars:
SECRET_FILE Local governance secret source (default: ~/.kill-life/mistral.env)
EOF
}
load_governance_key() {
python3 - "$SECRET_FILE" <<'PY'
import json
import sys
from pathlib import Path
secret_path = Path(sys.argv[1]).expanduser()
if not secret_path.exists():
raise SystemExit(f"missing secret file: {secret_path}")
value = None
for line in secret_path.read_text(encoding="utf-8").splitlines():
if line.startswith("MISTRAL_GOVERNANCE_API_KEY="):
value = line.split("=", 1)[1]
break
if not value:
raise SystemExit("MISTRAL_GOVERNANCE_API_KEY missing in secret file")
print(value)
PY
}
remote_status() {
local host="$1"
local secret_path="$2"
ssh -o BatchMode=yes -o ConnectTimeout=8 "$host" python3 - "$secret_path" <<'PY'
import json
import sys
from pathlib import Path
secret_path = Path(sys.argv[1])
print(json.dumps({
"status": "ok",
"secret_path": str(secret_path),
"exists": secret_path.exists(),
}))
PY
}
remote_sync() {
local host="$1"
local secret_path="$2"
local key_b64="$3"
ssh -o BatchMode=yes -o ConnectTimeout=8 "$host" python3 - "$secret_path" "$key_b64" <<'PY'
import base64
import json
import sys
from pathlib import Path
secret_path = Path(sys.argv[1])
key = base64.b64decode(sys.argv[2]).decode("utf-8")
secret_path.parent.mkdir(parents=True, exist_ok=True)
secret_path.write_text(f"MISTRAL_GOVERNANCE_API_KEY={key}\n", encoding="utf-8")
secret_path.chmod(0o600)
print(json.dumps({
"status": "updated",
"secret_path": str(secret_path),
}))
PY
}
run_action() {
local mode="$1"
local stamp artifact_file latest_file now
local key="" key_b64=""
if [[ "$mode" == "sync" ]]; then
key="$(load_governance_key)"
key_b64="$(printf '%s' "$key" | base64)"
fi
stamp="$(date +%Y%m%d_%H%M%S)"
artifact_file="${ARTIFACTS_DIR}/kill_life_mistral_governance_sync_${stamp}.json"
latest_file="${ARTIFACTS_DIR}/latest.json"
now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
python3 - "$mode" "$now" "$artifact_file" "$latest_file" "$key_b64" "${HOST_LABELS[@]}" -- "${HOST_TARGETS[@]}" -- "${HOST_SECRET_FILES[@]}" <<'PY'
import json
import subprocess
import sys
mode = sys.argv[1]
timestamp = sys.argv[2]
artifact_file = sys.argv[3]
latest_file = sys.argv[4]
key_b64 = sys.argv[5]
args = sys.argv[6:]
sep1 = args.index("--")
labels = args[:sep1]
args = args[sep1 + 1 :]
sep2 = args.index("--")
targets = args[:sep2]
secret_files = args[sep2 + 1 :]
results = []
for label, target, secret_path in zip(labels, targets, secret_files):
if target == "local":
if mode == "status":
proc = subprocess.run(
["python3", "-", secret_path],
input=(
"import json,sys\n"
"from pathlib import Path\n"
"p=Path(sys.argv[1]).expanduser()\n"
"print(json.dumps({'status':'ok','secret_path':str(p),'exists':p.exists()}))\n"
),
text=True,
capture_output=True,
)
else:
proc = subprocess.run(
["python3", "-", secret_path, key_b64],
input=(
"import base64,json,sys\n"
"from pathlib import Path\n"
"p=Path(sys.argv[1]).expanduser(); key=base64.b64decode(sys.argv[2]).decode('utf-8')\n"
"p.parent.mkdir(parents=True, exist_ok=True)\n"
"p.write_text(f'MISTRAL_GOVERNANCE_API_KEY={key}\\n', encoding='utf-8')\n"
"p.chmod(0o600)\n"
"print(json.dumps({'status':'updated','secret_path':str(p)}))\n"
),
text=True,
capture_output=True,
)
else:
subcmd = "__remote_status__" if mode == "status" else "__remote_sync__"
argv = [
"/Users/electron/Documents/Lelectron_rare/Kill_LIFE/tools/cockpit/kill_life_mistral_governance_sync.sh",
subcmd,
target,
secret_path,
]
if mode == "sync":
argv.append(key_b64)
proc = subprocess.run(argv, capture_output=True, text=True)
record = {"label": label, "host": target, "returncode": proc.returncode}
stdout = (proc.stdout or "").strip()
stderr = (proc.stderr or "").strip()
if stdout:
try:
record.update(json.loads(stdout.splitlines()[-1]))
except Exception:
record["status"] = "parse-error"
record["stdout"] = stdout
if stderr:
record["stderr"] = stderr
if "status" not in record:
record["status"] = "ssh-error" if proc.returncode else "empty"
results.append(record)
summary = {
"contract_version": "cockpit-v1",
"component": "kill-life-mistral-governance-sync",
"action": mode,
"timestamp": timestamp,
"results": results,
}
summary["ok"] = sum(1 for item in results if item.get("status") in {"ok", "updated"})
summary["errors"] = sum(1 for item in results if item.get("status") not in {"ok", "updated"})
payload = json.dumps(summary, indent=2) + "\n"
with open(artifact_file, "w", encoding="utf-8") as handle:
handle.write(payload)
with open(latest_file, "w", encoding="utf-8") as handle:
handle.write(payload)
print(payload, end="")
PY
}
if [[ "${1:-}" == "__remote_status__" ]]; then
shift
remote_status "$@"
exit 0
fi
if [[ "${1:-}" == "__remote_sync__" ]]; then
shift
remote_sync "$@"
exit 0
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--help|-h)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
case "$ACTION" in
sync|status)
run_action "$ACTION"
;;
*)
printf 'Unknown action: %s\n' "$ACTION" >&2
usage >&2
exit 2
;;
esac
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
# Load optional Kill_LIFE local Mistral governance secrets without touching repo .env files.
MISTRAL_GOVERNANCE_ENV_FILE="${KILL_LIFE_MISTRAL_ENV_FILE:-${HOME}/.kill-life/mistral.env}"
if [[ -f "${MISTRAL_GOVERNANCE_ENV_FILE}" ]]; then
# shellcheck disable=SC1090
. "${MISTRAL_GOVERNANCE_ENV_FILE}"
fi
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "${ROOT_DIR}/tools/cockpit/json_contract.sh"
ARTIFACT_DIR="$ROOT_DIR/artifacts/cockpit"
LOG_DIR="$ARTIFACT_DIR/log_ops"
mkdir -p "$LOG_DIR"
STAMP="$(date +%Y%m%d-%H%M%S)"
RUN_LOG="$LOG_DIR/log_ops-$STAMP.log"
RETENTION_DAYS="${RETENTION_DAYS:-7}"
ACTION="summary"
COMPONENT="log_ops"
JSON=0
APPLY=0
TARGET_DIRS=(
"$ROOT_DIR/artifacts"
"$ROOT_DIR/logs"
"$ROOT_DIR/tmp"
)
if [ -n "${LOG_OPS_TARGET_DIRS:-}" ]; then
IFS=':' read -r -a TARGET_DIRS <<< "${LOG_OPS_TARGET_DIRS}"
fi
usage() {
cat <<USAGE
Usage: bash tools/cockpit/log_ops.sh [--action summary|purge|list] [--json] [--apply] [--retention-days N]
Actions:
summary Summarize known logs and age buckets.
list Emit the file list considered by the tool.
purge Dry-run by default; delete logs older than retention only with --apply.
USAGE
}
log() {
local level="$1"
shift
local msg="$*"
printf '[%s] [%s] %s\n' "$(date +%H:%M:%S)" "$level" "$msg" | tee -a "$RUN_LOG" >&2
}
collect_logs() {
local dir
for dir in "${TARGET_DIRS[@]}"; do
[ -d "$dir" ] || continue
find "$dir" -type f \( -name '*.log' -o -name '*.out' -o -name '*.err' -o -name '*.jsonl' \) -print
done | sort -u
}
main() {
while [ "$#" -gt 0 ]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--json)
JSON=1
shift
;;
--apply)
APPLY=1
shift
;;
--retention-days)
RETENTION_DAYS="${2:-7}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
printf 'Unknown arg: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
local files
files="$(collect_logs || true)"
local count=0
local stale=0
local bytes=0
local listed_json=""
local purged_json=""
local purged_count=0
local status="done"
local contract_status="ok"
local artifacts=("${RUN_LOG}")
local degraded_reasons=()
local next_steps=()
if [ -n "$files" ]; then
while IFS= read -r file; do
[ -n "$file" ] || continue
count=$((count + 1))
local size
size="$(wc -c < "$file" | tr -d ' ')"
bytes=$((bytes + size))
listed_json="$(json_contract_append_string "$listed_json" "$file")"
if find "$file" -mtime +"$RETENTION_DAYS" -print -quit | grep -q .; then
stale=$((stale + 1))
if [ "$ACTION" = "purge" ] && [ "$APPLY" -eq 1 ]; then
rm -f "$file"
purged_json="$(json_contract_append_string "$purged_json" "$file")"
purged_count=$((purged_count + 1))
fi
fi
done <<EOF_INNER
$files
EOF_INNER
fi
case "$ACTION" in
summary)
if [ "$stale" -gt 0 ]; then
status="degraded"
degraded_reasons+=("stale-logs-detected")
next_steps+=("bash tools/cockpit/log_ops.sh --action purge --retention-days ${RETENTION_DAYS} --apply --json")
log WARN "$stale stale log(s) older than $RETENTION_DAYS day(s) detected"
else
log INFO "No stale logs detected"
fi
log INFO "Count=$count Bytes=$bytes"
;;
list)
log INFO "Listing $count candidate log(s)"
;;
purge)
if [ "$APPLY" -eq 1 ]; then
log INFO "Purged ${purged_count} stale log(s)"
else
log WARN "Dry-run purge only; re-run with --apply to delete stale logs"
if [ "$stale" -gt 0 ]; then
status="degraded"
degraded_reasons+=("dry-run-purge-pending")
next_steps+=("bash tools/cockpit/log_ops.sh --action purge --retention-days ${RETENTION_DAYS} --apply --json")
fi
fi
;;
*)
log ERROR "Unsupported action: $ACTION"
status="blocked"
degraded_reasons+=("unsupported-action")
next_steps+=("bash tools/cockpit/log_ops.sh --help")
;;
esac
contract_status="$(json_contract_map_status "${status}")"
if [ "$JSON" -eq 1 ]; then
printf '{\n'
printf ' "contract_version": "cockpit-v1",\n'
printf ' "component": "%s",\n' "${COMPONENT}"
printf ' "contract_status": "%s",\n' "${contract_status}"
printf ' "generated_at": "%s",\n' "$(date '+%Y-%m-%d %H:%M:%S %z')"
printf ' "log_file": "%s",\n' "${RUN_LOG}"
printf ' "artifacts": %s,\n' "$(json_contract_array_from_args "${artifacts[@]}")"
printf ' "degraded_reasons": %s,\n' "$(json_contract_array_from_args "${degraded_reasons[@]}")"
printf ' "next_steps": %s,\n' "$(json_contract_array_from_args "${next_steps[@]}")"
printf ' "status": "%s",\n' "$status"
printf ' "action": "%s",\n' "$ACTION"
printf ' "retention_days": %s,\n' "$RETENTION_DAYS"
printf ' "count": %s,\n' "$count"
printf ' "stale": %s,\n' "$stale"
printf ' "purged_count": %s,\n' "$purged_count"
printf ' "bytes": %s,\n' "$bytes"
printf ' "apply": %s,\n' "$APPLY"
printf ' "files": [%s],\n' "$listed_json"
printf ' "purged": [%s]\n' "$purged_json"
printf '}\n'
else
printf 'status=%s action=%s count=%s stale=%s bytes=%s retention_days=%s apply=%s\n' \
"$status" "$ACTION" "$count" "$stale" "$bytes" "$RETENTION_DAYS" "$APPLY"
fi
case "$status" in
blocked) exit 1 ;;
*) exit 0 ;;
esac
}
main "$@"
+105 -5
View File
@@ -9,6 +9,10 @@ TASKS_FILE="${ROOT_DIR}/specs/04_tasks.md"
STATUS_FILE="${ROOT_DIR}/artifacts/cockpit/useful_lots_status.md"
QUESTION_FILE="${ROOT_DIR}/artifacts/cockpit/next_question.md"
STATE_FILE="${ROOT_DIR}/artifacts/cockpit/last_validation.env"
INTELLIGENCE_MEMORY_FILE="${ROOT_DIR}/artifacts/cockpit/intelligence_program/latest.json"
INTELLIGENCE_MEMORY_MD="${ROOT_DIR}/artifacts/cockpit/intelligence_program/latest.md"
KILL_LIFE_MEMORY_FILE="${ROOT_DIR}/artifacts/cockpit/kill_life_memory/latest.json"
KILL_LIFE_MEMORY_MD="${ROOT_DIR}/artifacts/cockpit/kill_life_memory/latest.md"
COMMAND=""
VERBOSE=0
@@ -22,8 +26,12 @@ UPSTREAM_STATUS="unknown"
UPSTREAM_MODE="status"
NEXT_LOT="pending"
QUESTION_COUNT=0
INTELLIGENCE_STATUS="unknown"
INTELLIGENCE_CONTRACT_STATUS="unknown"
INTELLIGENCE_OPEN_TASK_COUNT=0
QUESTION_ITEMS=()
INTELLIGENCE_NEXT_STEPS=()
usage() {
cat <<'EOF'
@@ -155,6 +163,46 @@ refresh_upstream_autonomous_lane() {
fi
}
refresh_intelligence_lane() {
local tmp_json
local parsed=()
tmp_json="$(mktemp)"
if bash "${ROOT_DIR}/tools/cockpit/intelligence_tui.sh" --action memory --json >"${tmp_json}" 2>/dev/null; then
mapfile -t parsed < <(
python3 - "${tmp_json}" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
payload = json.load(handle)
print(payload.get("status", "unknown"))
print(payload.get("contract_status", "unknown"))
print(payload.get("open_task_count", 0))
for step in payload.get("next_steps", [])[:3]:
print(step)
PY
)
INTELLIGENCE_STATUS="${parsed[0]:-unknown}"
INTELLIGENCE_CONTRACT_STATUS="${parsed[1]:-unknown}"
INTELLIGENCE_OPEN_TASK_COUNT="${parsed[2]:-0}"
INTELLIGENCE_NEXT_STEPS=()
if [[ "${#parsed[@]}" -gt 3 ]]; then
INTELLIGENCE_NEXT_STEPS=("${parsed[@]:3}")
fi
else
INTELLIGENCE_STATUS="failed"
INTELLIGENCE_CONTRACT_STATUS="error"
INTELLIGENCE_OPEN_TASK_COUNT=0
INTELLIGENCE_NEXT_STEPS=("Refresh intelligence memory with \`bash tools/cockpit/intelligence_tui.sh --action memory --json\`.")
fi
rm -f "${tmp_json}"
if [[ ! -f "${KILL_LIFE_MEMORY_FILE}" || ! -f "${KILL_LIFE_MEMORY_MD}" ]]; then
INTELLIGENCE_NEXT_STEPS+=("Refresh kill_life continuity with \`bash tools/cockpit/write_kill_life_memory_entry.sh --component lot_chain --json\`.")
fi
}
collect_manual_questions() {
local file match line_no line_text clean_text item_id recommended recommended_text
@@ -162,12 +210,12 @@ collect_manual_questions() {
while IFS= read -r file; do
[[ -n "${file}" ]] || continue
match="$(rg -n '^- \[ \]' "${ROOT_DIR}/${file}" | head -n 1 || true)"
match="$(rg -n '^[[:space:]]*-[[:space:]]*\[ \]' "${ROOT_DIR}/${file}" | head -n 1 || true)"
if [[ -n "${match}" ]]; then
line_no="${match%%:*}"
line_text="${match#*:}"
clean_text="$(printf '%s\n' "${line_text}" | sed -E 's/^- \[ \] //')"
item_id="$(printf '%s\n' "${line_text}" | sed -E 's/^- \[ \] ([A-Z]-[0-9]+).*/\1/' )"
clean_text="$(printf '%s\n' "${line_text}" | sed -E 's/^[[:space:]]*-[[:space:]]*\[ \][[:space:]]*//')"
item_id="$(printf '%s\n' "${line_text}" | sed -E 's/^[[:space:]]*-[[:space:]]*\[ \][[:space:]]*([A-Z][A-Z-]*-[0-9]+).*/\1/' )"
if [[ "${item_id}" == "${line_text}" ]]; then
item_id="$(printf '%s\n' "${file}" | sed 's#specs/##; s#\.md##')"
fi
@@ -177,6 +225,7 @@ collect_manual_questions() {
QUESTION_ITEMS+=("${item_id}|${file}|${line_no}|${clean_text}")
fi
done <<'EOF'
specs/04_tasks.md
specs/mcp_tasks.md
specs/zeroclaw_dual_hw_todo.md
EOF
@@ -193,11 +242,28 @@ EOF
return 0
fi
if [[ "${QUESTION_COUNT}" -eq 1 ]]; then
NEXT_LOT="selected"
IFS='|' read -r item_id file line_no line_text <<< "${QUESTION_ITEMS[0]}"
{
printf '# Next manual lot\n\n'
printf 'Only one curated manual backlog item remains open, so it becomes the selected next active plan.\n\n'
printf '## Selected lot\n\n'
printf -- '- `%s` in `%s:%s` — %s\n' "${item_id}" "${file}" "${line_no}" "${line_text}"
} > "${QUESTION_FILE}"
return 0
fi
NEXT_LOT="question"
recommended=""
recommended_text=""
for item in "${QUESTION_ITEMS[@]}"; do
IFS='|' read -r item_id file line_no line_text <<< "${item}"
if [[ "${file}" == "specs/04_tasks.md" && "${item_id}" == "T-RE-204" ]]; then
recommended="${item_id}"
recommended_text="${line_text}"
break
fi
if [[ "${file}" == "specs/zeroclaw_dual_hw_todo.md" ]]; then
recommended="${item_id}"
recommended_text="${line_text}"
@@ -225,11 +291,11 @@ item_is_optional() {
local item_id="$2"
awk -v item="${item_id}" '
$0 ~ "^- \\[ \\] " item {
$0 ~ "^[[:space:]]*- \\[ \\][[:space:]]*" item {
in_item = 1
next
}
in_item && $0 ~ "^- \\[[ x]\\] [A-Z]-[0-9]+" {
in_item && $0 ~ "^[[:space:]]*- \\[[ x]\\][[:space:]]*[A-Z][A-Z-]*-[0-9]+" {
exit
}
in_item {
@@ -280,6 +346,23 @@ write_status_report() {
printf '\n'
printf -- ' - Command: `bash tools/test_python.sh --suite stable`\n\n'
printf '## Intelligence governance\n\n'
printf -- '- Intelligence memory: `%s`\n' "${INTELLIGENCE_STATUS}"
printf -- ' - Command: `bash tools/cockpit/intelligence_tui.sh --action memory --json`\n'
printf -- ' - Evidence: `%s`\n' "${INTELLIGENCE_MEMORY_FILE#${ROOT_DIR}/}"
printf -- ' - Markdown: `%s`\n' "${INTELLIGENCE_MEMORY_MD#${ROOT_DIR}/}"
printf -- ' - kill_life continuity JSON: `%s`\n' "${KILL_LIFE_MEMORY_FILE#${ROOT_DIR}/}"
printf -- ' - kill_life continuity Markdown: `%s`\n' "${KILL_LIFE_MEMORY_MD#${ROOT_DIR}/}"
printf -- ' - Open tasks: `%s`\n' "${INTELLIGENCE_OPEN_TASK_COUNT}"
if [[ "${#INTELLIGENCE_NEXT_STEPS[@]}" -gt 0 ]]; then
printf -- ' - Next actions:\n'
local step
for step in "${INTELLIGENCE_NEXT_STEPS[@]}"; do
printf ' - %s\n' "${step}"
done
fi
printf '\n'
printf '## Next step\n\n'
if [[ "${NEXT_LOT}" == "question" ]]; then
printf -- '- Manual choice required. See `%s`.\n' "${QUESTION_FILE#${ROOT_DIR}/}"
@@ -311,6 +394,8 @@ update_plan_files() {
printf -- '- MCP/CAD runtime lane sync: `%s`\n' "${UPSTREAM_STATUS}"
printf -- '- Strict spec contract: `%s`\n' "${STRICT_STATUS}"
printf -- '- Stable Python suite: `%s`\n' "${PYTHON_STATUS}"
printf -- '- Intelligence governance memory: `%s` (`%s` open tasks)\n' "${INTELLIGENCE_STATUS}" "${INTELLIGENCE_OPEN_TASK_COUNT}"
printf -- '- kill_life continuity memory: `%s`\n' "${KILL_LIFE_MEMORY_FILE#${ROOT_DIR}/}"
if [[ "${NEXT_LOT}" == "question" ]]; then
printf -- '- Next real need: ask the operator to choose the next manual lot from `%s`.\n' "${QUESTION_FILE#${ROOT_DIR}/}"
elif [[ "${NEXT_LOT}" == "none" ]]; then
@@ -356,6 +441,20 @@ update_plan_files() {
fi
printf -- ' - Evidence: `bash tools/test_python.sh --suite stable`\n'
if [[ "${INTELLIGENCE_CONTRACT_STATUS}" == "error" ]]; then
printf -- '- [ ] T-LC-007 - Keep the intelligence governance memory refreshed for automation surfaces.\n'
else
printf -- '- [x] T-LC-007 - Keep the intelligence governance memory refreshed for automation surfaces.\n'
fi
printf -- ' - Evidence: `artifacts/cockpit/intelligence_program/latest.json`\n'
if [[ -f "${KILL_LIFE_MEMORY_FILE}" ]]; then
printf -- '- [x] T-LC-008 - Keep the kill_life continuity memory visible from the chained cockpit entrypoints.\n'
else
printf -- '- [ ] T-LC-008 - Keep the kill_life continuity memory visible from the chained cockpit entrypoints.\n'
fi
printf -- ' - Evidence: `artifacts/cockpit/kill_life_memory/latest.json`\n'
if [[ "${NEXT_LOT}" == "question" ]]; then
printf -- '- [ ] T-LC-006 - Choose the next manual lot once automation reaches a real fork.\n'
printf -- ' - Evidence: `%s`\n' "${QUESTION_FILE#${ROOT_DIR}/}"
@@ -433,6 +532,7 @@ refresh_state() {
load_validation_state
refresh_auto_lot_status
refresh_upstream_autonomous_lane "${UPSTREAM_MODE}"
refresh_intelligence_lane
collect_manual_questions
write_status_report
}
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
source "${ROOT_DIR}/tools/cockpit/json_contract.sh"
REGISTRY_FILE="${ROOT_DIR}/specs/contracts/machine_registry.mesh.json"
LOG_DIR="${ROOT_DIR}/artifacts/cockpit"
mkdir -p "${LOG_DIR}"
ACTION=""
TARGET_ID=""
JSON=0
RETENTION_DAYS=14
LOG_FILE=""
usage() {
cat <<'EOF'
Usage: bash tools/cockpit/machine_registry.sh [options]
Options:
--action <summary|list|show|clean-logs> Action to run
--machine <id> Filter a single machine id
--json Emit JSON when available
--days <N> Retention for clean-logs (default: 14)
-h, --help Show this help
EOF
}
have_tty() {
[[ -t 0 && -t 1 ]]
}
choose_action_interactive() {
if command -v gum >/dev/null 2>&1 && have_tty; then
gum choose summary list show clean-logs
return 0
fi
return 1
}
log_line() {
local level="$1"
shift
local msg="${level} $(date '+%Y-%m-%d %H:%M:%S %z') ${*}"
printf '%s\n' "${msg}" | tee -a "${LOG_FILE}" >&2
}
require_registry() {
if [[ ! -f "${REGISTRY_FILE}" ]]; then
printf 'Missing registry file: %s\n' "${REGISTRY_FILE}" >&2
exit 1
fi
}
emit_summary() {
python3 - "${REGISTRY_FILE}" "${JSON}" "${LOG_FILE}" <<'PY'
import json
import sys
from pathlib import Path
registry_path = Path(sys.argv[1])
json_mode = sys.argv[2] == "1"
log_file = sys.argv[3]
data = json.loads(registry_path.read_text(encoding="utf-8"))
targets = data.get("targets", [])
reserve = [t["id"] for t in targets if t.get("reserve_only")]
order = [t["id"] for t in sorted(targets, key=lambda item: item.get("priority", 999))]
payload = {
"contract_version": "cockpit-v1",
"status": "ok",
"contract_status": "ok",
"component": "machine_registry",
"action": "summary",
"artifacts": [log_file, str(registry_path)],
"degraded_reasons": [],
"next_steps": [
"bash tools/cockpit/machine_registry.sh --action list --json",
"bash tools/cockpit/machine_registry.sh --action show --machine tower --json",
],
"registry_file": str(registry_path),
"default_profile": data.get("default_profile"),
"target_count": len(targets),
"reserve_targets": reserve,
"priority_order": order,
}
if json_mode:
print(json.dumps(payload, ensure_ascii=False))
else:
print("# Machine registry summary\n")
print(f"- registry: {payload['registry_file']}")
print(f"- default_profile: {payload['default_profile']}")
print(f"- target_count: {payload['target_count']}")
print(f"- reserve_targets: {', '.join(reserve) if reserve else 'none'}")
print(f"- priority_order: {' -> '.join(order)}")
PY
}
emit_targets() {
python3 - "${REGISTRY_FILE}" "${TARGET_ID}" "${JSON}" "${LOG_FILE}" <<'PY'
import json
import sys
from pathlib import Path
registry_path = Path(sys.argv[1])
target_id = sys.argv[2]
json_mode = sys.argv[3] == "1"
log_file = sys.argv[4]
data = json.loads(registry_path.read_text(encoding="utf-8"))
targets = data.get("targets", [])
if target_id:
targets = [item for item in targets if item.get("id") == target_id]
payload = {
"contract_version": "cockpit-v1",
"status": "ok",
"contract_status": "ok",
"component": "machine_registry",
"action": "show" if target_id else "list",
"artifacts": [log_file, str(registry_path)],
"degraded_reasons": [],
"next_steps": [
"bash tools/cockpit/machine_registry.sh --action summary --json",
],
"targets": targets,
}
if json_mode:
print(json.dumps(payload, ensure_ascii=False))
else:
if not targets:
print("no targets found")
raise SystemExit(0)
print("# Machine registry\n")
for item in targets:
print(f"- id: {item['id']}")
print(f" target: {item['target']}")
print(f" role: {item['role']}")
print(f" priority: {item['priority']}")
print(f" placement: {item['placement']}")
print(f" profiles: {', '.join(item.get('enabled_profiles', []))}")
print(f" reserve_only: {item.get('reserve_only')}")
print(f" critical_repos: {', '.join(item.get('critical_repos', []))}")
print(f" notes: {item.get('notes', '')}")
PY
}
emit_clean_logs() {
local stale_files=()
local pattern='machine_registry_*.log'
while IFS= read -r file; do
[[ -n "${file}" ]] || continue
stale_files+=("${file}")
done < <(find "${LOG_DIR}" -type f -name "${pattern}" -mtime +"${RETENTION_DAYS}" -print | sort)
local count="${#stale_files[@]}"
if [[ "${count}" -gt 0 ]]; then
find "${LOG_DIR}" -type f -name "${pattern}" -mtime +"${RETENTION_DAYS}" -delete
fi
if [[ "${JSON}" -eq 1 ]]; then
printf '{\n'
printf ' "contract_version": "cockpit-v1",\n'
printf ' "component": "machine_registry",\n'
printf ' "contract_status": "ok",\n'
printf ' "status": "ok",\n'
printf ' "action": "clean-logs",\n'
printf ' "artifacts": %s,\n' "$(json_contract_array_from_args "${LOG_FILE}" "${LOG_DIR}")"
printf ' "degraded_reasons": [],\n'
printf ' "next_steps": %s,\n' "$(json_contract_array_from_args "bash tools/cockpit/machine_registry.sh --action summary --json")"
printf ' "retention_days": %s,\n' "${RETENTION_DAYS}"
printf ' "deleted_count": %s,\n' "${count}"
printf ' "log_dir": "%s"\n' "${LOG_DIR}"
printf '}\n'
else
printf 'cleaned machine_registry logs older than %s days in %s (%s deleted)\n' "${RETENTION_DAYS}" "${LOG_DIR}" "${count}"
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--machine)
TARGET_ID="${2:-}"
shift 2
;;
--json)
JSON=1
shift
;;
--days)
RETENTION_DAYS="${2:-14}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
if [[ -z "${ACTION}" ]]; then
if ACTION="$(choose_action_interactive)"; then
:
else
ACTION="summary"
fi
fi
if ! [[ "${RETENTION_DAYS}" =~ ^[0-9]+$ ]]; then
printf -- '--days requires an integer\n' >&2
exit 2
fi
LOG_FILE="${LOG_DIR}/machine_registry_$(date '+%Y%m%d_%H%M%S').log"
require_registry
log_line "INFO" "action=${ACTION} target=${TARGET_ID:-all}"
case "${ACTION}" in
summary)
emit_summary
;;
list)
emit_targets
;;
show)
if [[ -z "${TARGET_ID}" ]]; then
printf -- '--machine is required with --action show\n' >&2
exit 2
fi
emit_targets
;;
clean-logs)
emit_clean_logs
;;
*)
printf 'Unknown action: %s\n' "${ACTION}" >&2
usage >&2
exit 2
;;
esac
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
DISPATCH_FILE="${ROOT_DIR}/specs/contracts/mascarade_dispatch.mesh.json"
REGISTRY_FILE="${ROOT_DIR}/specs/contracts/machine_registry.mesh.json"
ACTION="summary"
PROFILE_ID=""
FAMILY_ID=""
JSON_OUTPUT=0
usage() {
cat <<'EOF'
Usage: bash tools/cockpit/mascarade_dispatch_mesh.sh [options]
Options:
--action summary|route Default: summary
--profile ID Profile id to route
--family ID Explicit family override
--dispatch-file FILE Override dispatch contract
--registry-file FILE Override registry contract
--json Emit JSON
--help Show this help
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--profile)
PROFILE_ID="${2:-}"
shift 2
;;
--family)
FAMILY_ID="${2:-}"
shift 2
;;
--dispatch-file)
DISPATCH_FILE="${2:-}"
shift 2
;;
--registry-file)
REGISTRY_FILE="${2:-}"
shift 2
;;
--json)
JSON_OUTPUT=1
shift
;;
--help|-h)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ "${ACTION}" != "summary" && "${ACTION}" != "route" ]]; then
echo "Invalid --action: ${ACTION}" >&2
exit 2
fi
python3 - "${DISPATCH_FILE}" "${REGISTRY_FILE}" "${ACTION}" "${PROFILE_ID}" "${FAMILY_ID}" "${JSON_OUTPUT}" <<'PY'
import json
import sys
from pathlib import Path
dispatch = json.loads(Path(sys.argv[1]).read_text())
registry = json.loads(Path(sys.argv[2]).read_text())
action = sys.argv[3]
profile = sys.argv[4]
family_override = sys.argv[5]
json_output = sys.argv[6] == "1"
def registry_targets(data):
if isinstance(data.get("targets"), list):
return data["targets"]
if isinstance(data.get("targets"), dict):
return [{"id": key, **value} for key, value in data["targets"].items()]
if isinstance(data.get("machines"), list):
return data["machines"]
if isinstance(data.get("hosts"), list):
return data["hosts"]
return []
targets = registry_targets(registry)
target_map = {}
for item in targets:
if not isinstance(item, dict):
continue
ident = item.get("id") or item.get("name")
if ident:
target_map[ident] = item
default_order = dispatch.get("default_host_order", [])
profile_overrides = dispatch.get("profile_overrides", {})
family_defaults = dispatch.get("family_defaults", {})
keyword_families = dispatch.get("keyword_families", {})
def infer_family(profile_name):
profile_name = (profile_name or "").lower()
for family_name, keywords in keyword_families.items():
for keyword in keywords:
if keyword in profile_name:
return family_name
return "interactive-safe"
def resolve_family(profile_name, family_name):
if family_name:
return family_name
if profile_name in profile_overrides and profile_overrides[profile_name].get("family"):
return profile_overrides[profile_name]["family"]
return infer_family(profile_name)
family = resolve_family(profile, family_override)
override = profile_overrides.get(profile, {})
host_order = override.get("host_order") or family_defaults.get(family, {}).get("host_order") or default_order
host_order = [host for host in host_order if host in target_map] + [host for host in default_order if host not in host_order and host in target_map]
selected_target = host_order[0] if host_order else None
selected_meta = target_map.get(selected_target, {})
summary = {
"status": "ok",
"action": action,
"profile": profile or None,
"family": family,
"dispatch_file": sys.argv[1],
"registry_file": sys.argv[2],
"default_host_order": default_order,
"resolved_host_order": host_order,
"selected_target": selected_target,
"selected_host": selected_meta.get("host") or selected_meta.get("ssh_host"),
"selected_priority": selected_meta.get("priority"),
"selected_placement": selected_meta.get("placement"),
"notes": family_defaults.get(family, {}).get("notes"),
}
if json_output:
print(json.dumps(summary, indent=2, ensure_ascii=True))
else:
print("Mascarade dispatch mesh")
print(f"- action: {summary['action']}")
print(f"- family: {summary['family']}")
if summary["profile"]:
print(f"- profile: {summary['profile']}")
print(f"- route: {' -> '.join(summary['resolved_host_order'])}")
print(f"- selected: {summary['selected_target']} ({summary['selected_host']})")
PY
@@ -0,0 +1,234 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
COCKPIT_DIR="$ROOT_DIR/artifacts/cockpit"
OPERATOR_DIR="$ROOT_DIR/artifacts/operator_lane"
OUTPUT_MODE="text"
usage() {
cat <<'EOF'
Usage:
bash tools/cockpit/mascarade_incident_registry.sh [--json]
Options:
--json Emit cockpit-v1 JSON to stdout
-h,--help Show this help
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--json)
OUTPUT_MODE="json"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
mkdir -p "$COCKPIT_DIR" "$OPERATOR_DIR"
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
OUT_MD="$COCKPIT_DIR/mascarade_incident_registry_${RUN_ID}.md"
OUT_JSON="$COCKPIT_DIR/mascarade_incident_registry_${RUN_ID}.json"
python3 - "$COCKPIT_DIR" "$OPERATOR_DIR" "$OUT_MD" "$OUT_JSON" <<'PY'
import json
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
cockpit_dir = Path(sys.argv[1])
operator_dir = Path(sys.argv[2])
out_md = Path(sys.argv[3])
out_json = Path(sys.argv[4])
memory_json = cockpit_dir / "kill_life_memory" / "latest.json"
memory_md = cockpit_dir / "kill_life_memory" / "latest.md"
def load_json(path: Path):
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
def iter_files(directory: Path, prefix: str, suffix=".json", exclude=()):
if not directory.exists():
return []
items = []
for path in directory.iterdir():
if not path.is_file():
continue
if not path.name.startswith(prefix) or not path.name.endswith(suffix):
continue
if any(token in path.name for token in exclude):
continue
items.append(path)
return sorted(items, key=lambda p: p.stat().st_mtime, reverse=True)
brief_files = iter_files(cockpit_dir, "mascarade_incident_brief_", exclude=("latest",))
operator_files = iter_files(operator_dir, "full_operator_lane_", exclude=("mascarade_health", "mascarade_logs"))
memory_payload = load_json(memory_json) if memory_json.exists() else {}
memory_entry = memory_payload.get("entry", {}) if isinstance(memory_payload.get("entry"), dict) else {}
routing = memory_entry.get("routing", {}) if isinstance(memory_entry.get("routing"), dict) else {}
resume_ref = memory_payload.get("resume_ref") or memory_entry.get("resume_ref", "")
trust_level = memory_payload.get("trust_level") or memory_entry.get("trust_level", "inferred")
entries = []
for path in brief_files[:20]:
data = load_json(path)
entries.append({
"ts": data.get("generated_at", ""),
"source": "brief",
"status": data.get("status", "unknown"),
"path": str(path),
"reasons": data.get("degraded_reasons", []),
"next_steps": data.get("next_steps", []),
})
for path in operator_files[:20]:
data = load_json(path)
status = data.get("status", "unknown")
error = data.get("error", "")
if status in {"ok", "ready", "done", "success"} and not error:
continue
entries.append({
"ts": data.get("generated_at", ""),
"source": "operator-lane",
"status": status,
"path": str(path),
"reasons": [error] if error else [],
"next_steps": [data.get("suggested_command", "")] if data.get("suggested_command") else [],
})
def classify(entry):
status = (entry.get("status") or "").lower()
reasons = " ".join(entry.get("reasons", []))
if status in {"failed", "error", "blocked", "ko"}:
if "run-api-unreachable" in reasons or "poll-api-unreachable" in reasons:
return ("high", "P1")
if "status-api-unreachable" in reasons or "validate-api-unreachable" in reasons:
return ("medium", "P2")
return ("high", "P1")
if status in {"degraded"}:
return ("medium", "P2")
return ("low", "P3")
entries.sort(key=lambda item: item.get("ts", ""), reverse=True)
entries = entries[:25]
for entry in entries:
severity, priority = classify(entry)
entry["severity"] = severity
entry["priority"] = priority
overall = "ok" if not entries else "degraded"
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
severity_counts = {"high": 0, "medium": 0, "low": 0}
priority_counts = {"P1": 0, "P2": 0, "P3": 0}
for entry in entries:
severity_counts[entry["severity"]] = severity_counts.get(entry["severity"], 0) + 1
priority_counts[entry["priority"]] = priority_counts.get(entry["priority"], 0) + 1
lines = [
"# Mascarade incident registry",
"",
f"- generated_at: {generated_at}",
f"- entry_count: {len(entries)}",
f"- trust_level: {trust_level}",
f"- resume_ref: {resume_ref or 'missing'}",
f"- owner: {memory_entry.get('owner', 'unknown')}",
f"- selected_target: {routing.get('selected_target', 'unknown')}",
f"- kill_life_memory: {memory_md if memory_md.exists() else 'missing'}",
"",
"## Severity summary",
"",
"| Severity | Count | Priority |",
"| --- | --- | --- |",
f"| high | {severity_counts['high']} | P1 |",
f"| medium | {severity_counts['medium']} | P2 |",
f"| low | {severity_counts['low']} | P3 |",
"",
"## Incident table",
"",
"| Timestamp | Source | Status | Severity | Priority | Path | Reasons |",
"| --- | --- | --- | --- | --- | --- | --- |",
]
if entries:
for entry in entries:
reasons = ", ".join([r for r in entry.get("reasons", []) if r]) or "none"
lines.append(f"| {entry.get('ts') or 'n/a'} | {entry['source']} | {entry['status']} | {entry['severity']} | {entry['priority']} | `{entry['path']}` | {reasons} |")
else:
lines.append("| n/a | registry | ok | low | P3 | `n/a` | none |")
lines.extend(["", "## Next steps", ""])
if entries:
seen = []
for entry in entries:
for step in entry.get("next_steps", []):
if step and step not in seen:
seen.append(step)
lines.append(f"- {step}")
if not seen:
lines.append("- no explicit next step captured")
else:
lines.append("- no active incident captured")
out_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
shutil.copyfile(out_md, out_md.with_name("mascarade_incident_registry_latest.md"))
payload = {
"contract_version": "cockpit-v1",
"component": "mascarade-incident-registry",
"action": "render",
"status": overall,
"contract_status": overall,
"generated_at": generated_at,
"owner": memory_entry.get("owner", "SyncOps"),
"resume_ref": resume_ref,
"trust_level": trust_level,
"routing": routing,
"memory_entry": memory_entry,
"memory_markdown": str(memory_md) if memory_md.exists() else "",
"entry_count": len(entries),
"severity_counts": severity_counts,
"priority_counts": priority_counts,
"entries": entries,
"markdown_file": str(out_md),
"latest_markdown_file": str(out_md.with_name("mascarade_incident_registry_latest.md")),
"artifacts": [
str(out_md),
str(out_md.with_name("mascarade_incident_registry_latest.md")),
] + ([str(memory_json)] if memory_json.exists() else []),
"degraded_reasons": [f"{entry['source']}-{entry['status']}" for entry in entries[:5]],
"next_steps": [step for entry in entries for step in entry.get("next_steps", []) if step][:5],
}
out_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
shutil.copyfile(out_json, out_json.with_name("mascarade_incident_registry_latest.json"))
print(json.dumps(payload, ensure_ascii=False, indent=2))
PY
if [[ "$OUTPUT_MODE" == "json" ]]; then
cat "$OUT_JSON"
else
cat <<EOF
Mascarade incident registry
markdown: $OUT_MD
json: $OUT_JSON
EOF
fi
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
COCKPIT_DIR="$ROOT_DIR/artifacts/cockpit"
ACTION="summary"
LINES=18
JSON_OUTPUT=0
usage() {
cat <<'EOF'
Usage:
bash tools/cockpit/mascarade_incidents_tui.sh --action <summary|brief|registry|queue|daily|watch> [--lines N] [--json]
Options:
--action <name> summary|brief|registry|queue|daily|watch
--lines <int> Number of tail lines to display (default: 18)
--json Emit cockpit-v1 JSON to stdout
-h,--help Show this help
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--lines)
LINES="${2:-}"
shift 2
;;
--json)
JSON_OUTPUT=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ ! "$ACTION" =~ ^(summary|brief|registry|queue|daily|watch)$ ]]; then
echo "Invalid action: $ACTION" >&2
exit 2
fi
if ! [[ "$LINES" =~ ^[0-9]+$ ]]; then
echo "--lines must be an integer" >&2
exit 2
fi
mkdir -p "$COCKPIT_DIR"
python3 - "$COCKPIT_DIR" "$ACTION" "$LINES" "$JSON_OUTPUT" <<'PY'
import json
import sys
from pathlib import Path
cockpit_dir = Path(sys.argv[1])
action = sys.argv[2]
lines = int(sys.argv[3])
json_output = sys.argv[4] == "1"
paths = {
"brief": cockpit_dir / "mascarade_incident_brief_latest.md",
"registry": cockpit_dir / "mascarade_incident_registry_latest.md",
"queue": cockpit_dir / "mascarade_incident_queue_latest.md",
"daily": cockpit_dir / "daily_operator_summary_latest.md",
}
json_paths = {
"registry": cockpit_dir / "mascarade_incident_registry_latest.json",
"queue": cockpit_dir / "mascarade_incident_queue_latest.json",
"daily": cockpit_dir / "daily_operator_summary_latest.json",
}
memory_json = cockpit_dir / "kill_life_memory" / "latest.json"
memory_markdown = cockpit_dir / "kill_life_memory" / "latest.md"
def tail(path: Path, count: int):
try:
return path.read_text(encoding="utf-8", errors="replace").splitlines()[-count:]
except Exception:
return []
def load_json(path: Path):
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
def memory_context():
payload = load_json(memory_json) if memory_json.exists() else {}
entry = payload.get("entry", {}) if isinstance(payload.get("entry"), dict) else {}
routing = entry.get("routing", {}) if isinstance(entry.get("routing"), dict) else {}
return {
"owner": entry.get("owner", ""),
"resume_ref": payload.get("resume_ref") or entry.get("resume_ref", ""),
"trust_level": payload.get("trust_level") or entry.get("trust_level", "inferred"),
"routing": routing,
"memory_entry": entry,
"memory_artifact": str(memory_json) if memory_json.exists() else "",
"memory_markdown": str(memory_markdown) if memory_markdown.exists() else "",
}
def summary_payload():
entry = {}
for key, path in paths.items():
entry[key] = {
"path": str(path),
"exists": path.exists(),
"tail": tail(path, min(lines, 10)),
}
memory = memory_context()
artifacts = [item["path"] for item in entry.values() if item["exists"]]
if memory["memory_artifact"]:
artifacts.append(memory["memory_artifact"])
status = "ok" if any(item["exists"] for item in entry.values()) else "degraded"
return {
"contract_version": "cockpit-v1",
"component": "mascarade-incidents-tui",
"action": "summary",
"status": status,
"contract_status": status,
"owner": memory["owner"] or "SyncOps",
"resume_ref": memory["resume_ref"],
"trust_level": memory["trust_level"],
"routing": memory["routing"],
"memory_entry": memory["memory_entry"],
"entries": entry,
"artifacts": artifacts,
"degraded_reasons": [] if status == "ok" else ["mascarade-incidents-missing"],
"next_steps": [] if status == "ok" else ["Run the daily and incident rendering scripts to regenerate latest incident artifacts."],
}
def document_payload(name: str):
path = paths[name]
exists = path.exists()
status = "ok" if exists else "degraded"
memory = memory_context()
artifacts = [str(path)] if exists else []
if memory["memory_artifact"]:
artifacts.append(memory["memory_artifact"])
return {
"contract_version": "cockpit-v1",
"component": "mascarade-incidents-tui",
"action": name,
"status": status,
"contract_status": status,
"owner": memory["owner"] or "SyncOps",
"resume_ref": memory["resume_ref"],
"trust_level": memory["trust_level"],
"routing": memory["routing"],
"memory_entry": memory["memory_entry"],
"path": str(path),
"exists": exists,
"tail": tail(path, lines) if exists else [],
"artifacts": artifacts,
"degraded_reasons": [] if exists else [f"missing-{name}-artifact"],
"next_steps": [] if exists else [f"Regenerate the latest {name} artifact before review."],
}
def watch_payload():
registry = load_json(json_paths["registry"])
queue = load_json(json_paths["queue"])
daily = load_json(json_paths["daily"])
memory = memory_context()
priority_counts = registry.get("priority_counts", {}) if isinstance(registry.get("priority_counts"), dict) else {}
severity_counts = registry.get("severity_counts", {}) if isinstance(registry.get("severity_counts"), dict) else {}
entries = queue.get("entries", []) if isinstance(queue.get("entries"), list) else []
top_entries = []
for entry in entries[:5]:
top_entries.append({
"priority": entry.get("priority", "P3"),
"severity": entry.get("severity", "low"),
"status": entry.get("status", "unknown"),
"source": entry.get("source", "unknown"),
"timestamp": entry.get("ts", ""),
"reasons": entry.get("reasons", []),
})
next_steps = []
for candidate in (
queue.get("next_steps", []),
registry.get("next_steps", []),
daily.get("next_steps", []),
):
if isinstance(candidate, list):
for step in candidate:
if step and step not in next_steps:
next_steps.append(step)
status = "ok" if json_paths["queue"].exists() or json_paths["registry"].exists() else "degraded"
artifacts = [str(path) for path in json_paths.values() if path.exists()]
if memory["memory_artifact"]:
artifacts.append(memory["memory_artifact"])
return {
"contract_version": "cockpit-v1",
"component": "mascarade-incidents-tui",
"action": "watch",
"status": status,
"contract_status": status,
"owner": memory["owner"] or "SyncOps",
"resume_ref": memory["resume_ref"],
"trust_level": memory["trust_level"],
"routing": memory["routing"],
"memory_entry": memory["memory_entry"],
"priority_counts": priority_counts,
"severity_counts": severity_counts,
"top_entries": top_entries,
"artifacts": artifacts,
"degraded_reasons": [] if status == "ok" else ["incident-watch-missing-artifacts"],
"next_steps": next_steps[:5] if next_steps else ["Regenerate queue and registry artifacts before operator watch review."],
}
if action == "summary":
payload = summary_payload()
elif action == "watch":
payload = watch_payload()
else:
payload = document_payload(action)
if json_output:
print(json.dumps(payload, ensure_ascii=False, indent=2))
raise SystemExit(0)
if action == "summary":
print("Mascarade incidents summary")
print(f"trust_level: {payload.get('trust_level', 'inferred')}")
print(f"resume_ref: {payload.get('resume_ref') or 'n/a'}")
for key, item in payload["entries"].items():
print(f"{key}: {'ok' if item['exists'] else 'missing'} -> {item['path']}")
for line in item["tail"]:
print(f" {line}")
elif action == "watch":
print("Mascarade incident watch")
print(f"trust_level: {payload.get('trust_level', 'inferred')}")
print(f"resume_ref: {payload.get('resume_ref') or 'n/a'}")
routing = payload.get("routing", {}) if isinstance(payload.get("routing"), dict) else {}
print(f"routing: {routing.get('selected_target', 'unknown')} -> {routing.get('selected_host', 'unknown')}")
priority = payload.get("priority_counts", {})
severity = payload.get("severity_counts", {})
print(f"priority P1/P2/P3: {priority.get('P1', 0)}/{priority.get('P2', 0)}/{priority.get('P3', 0)}")
print(f"severity high/medium/low: {severity.get('high', 0)}/{severity.get('medium', 0)}/{severity.get('low', 0)}")
print("top queue:")
for entry in payload.get("top_entries", []):
reasons = ", ".join(entry.get("reasons", [])) if entry.get("reasons") else "none"
print(f" - {entry.get('priority')} {entry.get('severity')} {entry.get('status')} {entry.get('source')} {entry.get('timestamp') or 'n/a'} :: {reasons}")
if not payload.get("top_entries"):
print(" - no queued incident")
print("next steps:")
for step in payload.get("next_steps", []):
print(f" - {step}")
else:
print(f"Mascarade incidents view: {action}")
print(f"trust_level: {payload.get('trust_level', 'inferred')}")
print(f"resume_ref: {payload.get('resume_ref') or 'n/a'}")
print(f"path: {payload['path']}")
if payload["tail"]:
for line in payload["tail"]:
print(line)
else:
print("No content available.")
PY
+379
View File
@@ -0,0 +1,379 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RUNTIME_DIR="$ROOT_DIR/artifacts/ops/mascarade_runtime_health"
OPERATOR_DIR="$ROOT_DIR/artifacts/operator_lane"
ACTION="summary"
RETENTION_DAYS=14
LINES=20
JSON_OUTPUT=0
APPLY=0
usage() {
cat <<'EOF'
Usage:
bash tools/cockpit/mascarade_logs_tui.sh --action <summary|list|latest|purge> [options]
Options:
--action <name> summary|list|latest|purge
--days <int> Retention window for purge/list analysis (default: 14)
--lines <int> Max entries or log lines to display (default: 20)
--apply Apply purge instead of dry-run
--json Emit cockpit-v1 JSON
-h, --help Show this help
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
[[ $# -ge 2 ]] || { echo "--action requires a value" >&2; exit 2; }
ACTION="$2"
shift 2
;;
--days)
[[ $# -ge 2 ]] || { echo "--days requires a value" >&2; exit 2; }
RETENTION_DAYS="$2"
shift 2
;;
--lines)
[[ $# -ge 2 ]] || { echo "--lines requires a value" >&2; exit 2; }
LINES="$2"
shift 2
;;
--apply)
APPLY=1
shift
;;
--json)
JSON_OUTPUT=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ ! "$ACTION" =~ ^(summary|list|latest|purge)$ ]]; then
echo "Invalid action: $ACTION" >&2
exit 2
fi
if ! [[ "$RETENTION_DAYS" =~ ^[0-9]+$ ]] || ! [[ "$LINES" =~ ^[0-9]+$ ]]; then
echo "--days and --lines must be integers" >&2
exit 2
fi
mkdir -p "$RUNTIME_DIR" "$OPERATOR_DIR"
python3 - "$ACTION" "$ROOT_DIR" "$RUNTIME_DIR" "$OPERATOR_DIR" "$RETENTION_DAYS" "$LINES" "$JSON_OUTPUT" "$APPLY" <<'PY'
import json
import os
import sys
import time
from pathlib import Path
action, root_dir, runtime_dir_raw, operator_dir_raw, retention_days_raw, lines_raw, json_output_raw, apply_raw = sys.argv[1:]
root = Path(root_dir)
runtime_dir = Path(runtime_dir_raw)
operator_dir = Path(operator_dir_raw)
retention_days = int(retention_days_raw)
lines = int(lines_raw)
json_output = json_output_raw == "1"
apply = apply_raw == "1"
now = time.time()
cutoff = now - retention_days * 86400
memory_json = root / "artifacts" / "cockpit" / "kill_life_memory" / "latest.json"
memory_md = root / "artifacts" / "cockpit" / "kill_life_memory" / "latest.md"
def read_json(path: Path):
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
def memory_context():
payload = read_json(memory_json) if memory_json.exists() else {}
if not isinstance(payload, dict):
payload = {}
entry = payload.get("entry", {}) if isinstance(payload.get("entry"), dict) else {}
routing = entry.get("routing", {}) if isinstance(entry.get("routing"), dict) else {}
return {
"owner": entry.get("owner", "SyncOps"),
"resume_ref": payload.get("resume_ref") or entry.get("resume_ref", ""),
"trust_level": payload.get("trust_level") or entry.get("trust_level", "inferred"),
"routing": routing,
"memory_entry": entry,
"memory_artifact": str(memory_json) if memory_json.exists() else "",
"memory_markdown": str(memory_md) if memory_md.exists() else "",
}
def runtime_files():
if not runtime_dir.exists():
return []
return sorted([p for p in runtime_dir.iterdir() if p.is_file()], key=lambda p: p.stat().st_mtime, reverse=True)
def operator_health_files():
if not operator_dir.exists():
return []
files = [p for p in operator_dir.iterdir() if p.is_file() and p.name.startswith("full_operator_lane_mascarade_health_") and p.suffix == ".json"]
return sorted(files, key=lambda p: p.stat().st_mtime, reverse=True)
def candidate_purge_files():
candidates = []
for path in runtime_files():
if path.name.startswith("latest."):
continue
if path.stat().st_mtime < cutoff:
candidates.append(path)
for path in operator_health_files():
if path.stat().st_mtime < cutoff:
candidates.append(path)
return sorted(candidates, key=lambda p: p.stat().st_mtime)
def latest_runtime_summary():
latest_json = runtime_dir / "latest.json"
data = read_json(latest_json) if latest_json.exists() else None
if not isinstance(data, dict):
return {
"status": "missing",
"provider": "unknown",
"model": "unknown",
"checked_at": "",
"path": str(latest_json),
}
return {
"status": data.get("status", "unknown"),
"provider": data.get("provider", "unknown"),
"model": data.get("model", "unknown"),
"checked_at": data.get("checked_at", ""),
"path": str(latest_json),
}
def latest_operator_summary():
files = operator_health_files()
if not files:
return {"status": "missing", "path": "", "checked_at": ""}
latest = files[0]
data = read_json(latest)
if not isinstance(data, dict):
return {"status": "invalid", "path": str(latest), "checked_at": ""}
return {
"status": data.get("status", "unknown"),
"path": str(latest),
"checked_at": data.get("checked_at", data.get("generated_at", "")),
"provider": data.get("provider", "unknown"),
"model": data.get("model", "unknown"),
}
def tail_lines(path: Path, count: int):
if not path.exists():
return []
try:
return path.read_text(encoding="utf-8", errors="replace").splitlines()[-count:]
except Exception:
return []
def format_ts(path: Path):
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(path.stat().st_mtime))
def summary_payload():
memory = memory_context()
latest = latest_runtime_summary()
operator = latest_operator_summary()
files_runtime = runtime_files()
files_operator = operator_health_files()
stale = candidate_purge_files()
status = latest["status"]
if status in {"ok", "ready", "done", "success"}:
overall = "ok"
elif status in {"missing", "unknown"}:
overall = "degraded"
else:
overall = "degraded"
latest_log = runtime_dir / "latest.log"
payload = {
"contract_version": "cockpit-v1",
"component": "mascarade-logs-tui",
"action": "summary",
"status": overall,
"contract_status": overall,
"owner": memory["owner"],
"resume_ref": memory["resume_ref"],
"trust_level": memory["trust_level"],
"routing": memory["routing"],
"memory_entry": memory["memory_entry"],
"memory_markdown": memory["memory_markdown"],
"retention_days": retention_days,
"runtime_dir": str(runtime_dir),
"operator_dir": str(operator_dir),
"runtime_file_count": len(files_runtime),
"operator_health_file_count": len(files_operator),
"stale_candidate_count": len(stale),
"latest_runtime": latest,
"latest_operator_health": operator,
"latest_log_tail": tail_lines(latest_log, min(lines, 12)),
"artifacts": [str(p) for p in [latest_log, runtime_dir / "latest.json"] if p.exists()] + ([memory["memory_artifact"]] if memory["memory_artifact"] else []),
"degraded_reasons": [] if overall == "ok" else [f"mascarade-runtime-{status}"],
"next_steps": [] if overall == "ok" else ["bash tools/cockpit/mascarade_runtime_health.sh --json"],
}
return payload
def list_payload():
memory = memory_context()
combined = runtime_files() + operator_health_files()
combined = sorted(combined, key=lambda p: p.stat().st_mtime, reverse=True)[:lines]
entries = [
{
"file": str(path),
"kind": "operator-health" if path.parent == operator_dir else "runtime-artifact",
"modified_at": format_ts(path),
"size_bytes": path.stat().st_size,
}
for path in combined
]
status = "ok" if entries else "degraded"
return {
"contract_version": "cockpit-v1",
"component": "mascarade-logs-tui",
"action": "list",
"status": status,
"contract_status": status,
"owner": memory["owner"],
"resume_ref": memory["resume_ref"],
"trust_level": memory["trust_level"],
"routing": memory["routing"],
"memory_entry": memory["memory_entry"],
"memory_markdown": memory["memory_markdown"],
"entries": entries,
"artifacts": [entry["file"] for entry in entries] + ([memory["memory_artifact"]] if memory["memory_artifact"] else []),
"degraded_reasons": [] if entries else ["mascarade-logs-empty"],
"next_steps": [] if entries else ["Run bash tools/cockpit/mascarade_runtime_health.sh --json to create fresh artifacts."],
}
def latest_payload():
memory = memory_context()
summary = summary_payload()
latest_runtime_log = runtime_dir / "latest.log"
payload = {
"contract_version": "cockpit-v1",
"component": "mascarade-logs-tui",
"action": "latest",
"status": summary["status"],
"contract_status": summary["contract_status"],
"owner": memory["owner"],
"resume_ref": memory["resume_ref"],
"trust_level": memory["trust_level"],
"routing": memory["routing"],
"memory_entry": memory["memory_entry"],
"memory_markdown": memory["memory_markdown"],
"latest_runtime": summary["latest_runtime"],
"latest_operator_health": summary["latest_operator_health"],
"latest_log_tail": tail_lines(latest_runtime_log, lines),
"artifacts": summary["artifacts"],
"degraded_reasons": summary["degraded_reasons"],
"next_steps": summary["next_steps"],
}
return payload
def purge_payload():
candidates = candidate_purge_files()
purged = []
if apply:
for path in candidates:
try:
path.unlink()
purged.append(str(path))
except FileNotFoundError:
pass
status = "done" if apply else "ready"
contract_status = "ok" if apply else "ready"
return {
"contract_version": "cockpit-v1",
"component": "mascarade-logs-tui",
"action": "purge",
"status": status,
"contract_status": contract_status,
"retention_days": retention_days,
"apply": apply,
"candidate_count": len(candidates),
"purged_count": len(purged),
"candidates": [str(path) for path in candidates[:lines]],
"artifacts": purged if apply else [str(path) for path in candidates[:lines]],
"degraded_reasons": [],
"next_steps": [] if apply else ["Re-run with --apply to remove stale Mascarade/Ollama artifacts."],
}
if action == "summary":
payload = summary_payload()
elif action == "list":
payload = list_payload()
elif action == "latest":
payload = latest_payload()
else:
payload = purge_payload()
if json_output:
print(json.dumps(payload, ensure_ascii=False, indent=2))
raise SystemExit(0)
if action == "summary":
print("Mascarade/Ollama logs summary")
print(f"runtime dir: {payload['runtime_dir']}")
print(f"operator dir: {payload['operator_dir']}")
print(f"latest runtime: {payload['latest_runtime']['status']} ({payload['latest_runtime']['provider']}/{payload['latest_runtime']['model']})")
if payload["latest_runtime"]["checked_at"]:
print(f"checked at: {payload['latest_runtime']['checked_at']}")
print(f"runtime files: {payload['runtime_file_count']}")
print(f"operator health files: {payload['operator_health_file_count']}")
print(f"stale candidates (> {retention_days}j): {payload['stale_candidate_count']}")
if payload["latest_operator_health"]["path"]:
print(f"latest operator health: {payload['latest_operator_health']['status']} ({payload['latest_operator_health']['path']})")
if payload["latest_log_tail"]:
print("latest.log tail:")
for line in payload["latest_log_tail"]:
print(f" {line}")
elif action == "list":
print("Mascarade/Ollama log artifacts")
for entry in payload["entries"]:
print(f"{entry['modified_at']} | {entry['kind']} | {entry['size_bytes']} B | {entry['file']}")
elif action == "latest":
print("Mascarade/Ollama latest state")
print(f"runtime: {payload['latest_runtime']['status']} ({payload['latest_runtime']['provider']}/{payload['latest_runtime']['model']})")
print(f"runtime json: {payload['latest_runtime']['path']}")
if payload["latest_operator_health"]["path"]:
print(f"operator health: {payload['latest_operator_health']['status']} ({payload['latest_operator_health']['path']})")
print("latest.log tail:")
for line in payload["latest_log_tail"]:
print(f" {line}")
else:
mode = "apply" if apply else "dry-run"
print(f"Mascarade/Ollama purge ({mode})")
print(f"retention days: {retention_days}")
print(f"candidate count: {payload['candidate_count']}")
print(f"purged count: {payload['purged_count']}")
for item in payload["candidates"]:
print(f" {item}")
PY
+354
View File
@@ -0,0 +1,354 @@
#!/usr/bin/env bash
set -euo pipefail
# mascarade_mesh_env_sync.sh
# Canonical Mascarade roots and .env propagation for mesh machines.
# Contract: cockpit-v1
# Date: 2026-03-22
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ARTIFACTS_DIR="${ROOT_DIR}/artifacts/cockpit/mascarade_mesh_env_sync"
mkdir -p "${ARTIFACTS_DIR}"
ACTION="status"
JSON_MODE=0
SOURCE_ENV="${SOURCE_ENV:-/Users/electron/Documents/Projets/mascarade/.env}"
KEYS=(
"ANTHROPIC_API_KEY"
"MISTRAL_API_KEY"
"OPENAI_API_KEY"
)
HOST_LABELS=(
"clems"
"root"
"kxkm"
"cils"
)
HOST_TARGETS=(
"clems@192.168.0.120"
"root@192.168.0.119"
"kxkm@kxkm-ai"
"cils@100.126.225.111"
)
HOST_ROOTS=(
"/home/clems/mascarade"
"/root/mascarade-main"
"/home/kxkm/mascarade"
"/Users/cils/mascarade-main"
)
usage() {
cat <<'EOF'
Usage: mascarade_mesh_env_sync.sh [--action status|sync|paths] [--json]
Actions:
status Inspect canonical Mascarade roots and .env presence on the mesh
sync Propagate selected keys from the local source .env to the remote roots
paths Print the canonical roots only
Options:
--json Emit cockpit-v1 JSON
--help Show this help
Env vars:
SOURCE_ENV Local source .env (default: /Users/electron/Documents/Projets/mascarade/.env)
EOF
}
json_escape() {
python3 - "$1" <<'PY'
import json
import sys
print(json.dumps(sys.argv[1]))
PY
}
load_updates_json() {
python3 - "$SOURCE_ENV" "${KEYS[@]}" <<'PY'
import json
import sys
from pathlib import Path
env_path = Path(sys.argv[1])
keys = sys.argv[2:]
values = {}
for line in env_path.read_text(encoding="utf-8").splitlines():
if "=" not in line or line.lstrip().startswith("#"):
continue
key, value = line.split("=", 1)
if key in keys:
values[key] = value
missing = [key for key in keys if key not in values or not values[key]]
if missing:
raise SystemExit("missing keys in source env: " + ", ".join(missing))
print(json.dumps(values))
PY
}
remote_status() {
local host="$1"
local root="$2"
ssh -o BatchMode=yes -o ConnectTimeout=8 "$host" python3 - "$root" <<'PY'
import json
import socket
import sys
from pathlib import Path
root = Path(sys.argv[1])
ports = {}
for port in (3000, 3100, 8000, 8080, 11434):
s = socket.socket()
s.settimeout(0.4)
try:
s.connect(("127.0.0.1", port))
ports[str(port)] = True
except Exception:
ports[str(port)] = False
finally:
s.close()
print(json.dumps({
"status": "ok",
"hostname": socket.gethostname(),
"root": str(root),
"root_exists": root.exists(),
"env_path": str(root / ".env"),
"env_exists": (root / ".env").exists(),
"git_exists": (root / ".git").exists(),
"compose_exists": any((root / name).exists() for name in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml")),
"ports": ports,
}))
PY
}
remote_sync() {
local host="$1"
local root="$2"
local payload_b64="$3"
ssh -o BatchMode=yes -o ConnectTimeout=8 "$host" python3 - "$root" "$payload_b64" <<'PY'
import base64
import json
import sys
from pathlib import Path
root = Path(sys.argv[1])
updates = json.loads(base64.b64decode(sys.argv[2]).decode("utf-8"))
if not root.exists():
print(json.dumps({
"status": "missing-root",
"root": str(root),
}))
raise SystemExit(0)
env_path = root / ".env"
lines = env_path.read_text(encoding="utf-8").splitlines() if env_path.exists() else []
seen = set()
out = []
for line in lines:
replaced = False
for key, value in updates.items():
if line.startswith(f"{key}="):
out.append(f"{key}={value}")
seen.add(key)
replaced = True
break
if not replaced:
out.append(line)
for key, value in updates.items():
if key not in seen:
out.append(f"{key}={value}")
env_path.write_text("\n".join(out) + "\n", encoding="utf-8")
print(json.dumps({
"status": "updated",
"root": str(root),
"env_path": str(env_path),
"updated_keys": sorted(updates.keys()),
}))
PY
}
emit_paths_text() {
local idx
for idx in "${!HOST_LABELS[@]}"; do
printf '%s %s %s\n' "${HOST_LABELS[$idx]}" "${HOST_TARGETS[$idx]}" "${HOST_ROOTS[$idx]}"
done
}
run_action() {
local stamp artifact_file latest_file now
local mode="$1"
local updates_json=""
if [[ "$mode" == "sync" ]]; then
updates_json="$(load_updates_json)"
fi
stamp="$(date +%Y%m%d_%H%M%S)"
artifact_file="${ARTIFACTS_DIR}/mascarade_mesh_env_sync_${stamp}.json"
latest_file="${ARTIFACTS_DIR}/latest.json"
now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
python3 - "$mode" "$now" "$SOURCE_ENV" "$artifact_file" "$latest_file" "$updates_json" "${HOST_LABELS[@]}" -- "${HOST_TARGETS[@]}" -- "${HOST_ROOTS[@]}" <<'PY'
import json
import subprocess
import sys
mode = sys.argv[1]
timestamp = sys.argv[2]
source_env = sys.argv[3]
artifact_file = sys.argv[4]
latest_file = sys.argv[5]
updates_json = sys.argv[6]
args = sys.argv[7:]
sep1 = args.index("--")
labels = args[:sep1]
args = args[sep1 + 1 :]
sep2 = args.index("--")
targets = args[:sep2]
roots = args[sep2 + 1 :]
results = []
updates = json.loads(updates_json) if updates_json else {}
for label, target, root in zip(labels, targets, roots):
if mode == "status":
proc = subprocess.run(
[
"/Users/electron/Documents/Lelectron_rare/Kill_LIFE/tools/cockpit/mascarade_mesh_env_sync.sh",
"__remote_status__",
target,
root,
],
capture_output=True,
text=True,
)
else:
import base64
proc = subprocess.run(
[
"/Users/electron/Documents/Lelectron_rare/Kill_LIFE/tools/cockpit/mascarade_mesh_env_sync.sh",
"__remote_sync__",
target,
root,
base64.b64encode(json.dumps(updates).encode("utf-8")).decode("ascii"),
],
capture_output=True,
text=True,
)
record = {"label": label, "host": target, "returncode": proc.returncode}
stdout = (proc.stdout or "").strip()
stderr = (proc.stderr or "").strip()
if stdout:
try:
record.update(json.loads(stdout.splitlines()[-1]))
except Exception:
record["status"] = "parse-error"
record["stdout"] = stdout
if stderr:
record["stderr"] = stderr
if "status" not in record:
record["status"] = "ssh-error" if proc.returncode else "empty"
results.append(record)
summary = {
"contract_version": "cockpit-v1",
"component": "mascarade-mesh-env-sync",
"action": mode,
"timestamp": timestamp,
"source_env": source_env,
"updated_keys": sorted(updates.keys()),
"results": results,
}
summary["ok"] = sum(1 for item in results if item.get("status") in {"ok", "updated"})
summary["missing_root"] = sum(1 for item in results if item.get("status") == "missing-root")
summary["ssh_errors"] = sum(1 for item in results if item.get("status") == "ssh-error")
payload = json.dumps(summary, indent=2) + "\n"
with open(artifact_file, "w", encoding="utf-8") as handle:
handle.write(payload)
with open(latest_file, "w", encoding="utf-8") as handle:
handle.write(payload)
print(payload, end="")
PY
}
if [[ "${1:-}" == "__remote_status__" ]]; then
shift
remote_status "$@"
exit 0
fi
if [[ "${1:-}" == "__remote_sync__" ]]; then
shift
remote_sync "$@"
exit 0
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--json)
JSON_MODE=1
shift
;;
--help|-h)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
case "$ACTION" in
paths)
if [[ "$JSON_MODE" -eq 1 ]]; then
cat <<EOF
{
"contract_version": "cockpit-v1",
"component": "mascarade-mesh-env-sync",
"action": "paths",
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"source_env": $(json_escape "$SOURCE_ENV"),
"machines": [
{"label":"${HOST_LABELS[0]}","host":"${HOST_TARGETS[0]}","root":"${HOST_ROOTS[0]}"},
{"label":"${HOST_LABELS[1]}","host":"${HOST_TARGETS[1]}","root":"${HOST_ROOTS[1]}"},
{"label":"${HOST_LABELS[2]}","host":"${HOST_TARGETS[2]}","root":"${HOST_ROOTS[2]}"},
{"label":"${HOST_LABELS[3]}","host":"${HOST_TARGETS[3]}","root":"${HOST_ROOTS[3]}"}
]
}
EOF
else
emit_paths_text
fi
;;
status|sync)
run_action "$ACTION"
;;
*)
printf 'Unknown action: %s\n' "$ACTION" >&2
usage >&2
exit 2
;;
esac
+287
View File
@@ -0,0 +1,287 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
CATALOG_FILE="${ROOT_DIR}/specs/contracts/mascarade_model_profiles.kxkm_ai.json"
LOG_DIR="${ROOT_DIR}/artifacts/cockpit"
mkdir -p "${LOG_DIR}"
ACTION=""
PROFILE=""
JSON=0
DAYS=14
LOG_FILE="${LOG_DIR}/mascarade_models_tui_$(date '+%Y%m%d_%H%M%S').log"
usage() {
cat <<'EOF'
Usage: bash tools/cockpit/mascarade_models_tui.sh [options]
Options:
--action <summary|list|show|env|prompt|agents-json|clean-logs> Action to run
--profile <id> Profile id for show/env/prompt
--json Emit JSON when available
--days <N> Retention for clean-logs (default: 14)
-h, --help Show this help
EOF
}
have_tty() {
[[ -t 0 && -t 1 ]]
}
choose_action_interactive() {
if command -v gum >/dev/null 2>&1 && have_tty; then
gum choose summary list show env prompt agents-json clean-logs
return 0
fi
return 1
}
choose_profile_interactive() {
if command -v gum >/dev/null 2>&1 && have_tty; then
python3 - "${CATALOG_FILE}" <<'PY' | gum choose
import json
import sys
from pathlib import Path
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
for item in data.get("profiles", []):
if isinstance(item, dict):
profile_id = str(item.get("id", "")).strip()
if profile_id:
print(profile_id)
PY
return 0
fi
return 1
}
log_line() {
local level="$1"
shift
local msg="${level} $(date '+%Y-%m-%d %H:%M:%S %z') ${*}"
printf '%s\n' "${msg}" | tee -a "${LOG_FILE}" >/dev/null
}
require_catalog() {
if [[ ! -f "${CATALOG_FILE}" ]]; then
printf 'Missing catalog file: %s\n' "${CATALOG_FILE}" >&2
exit 1
fi
}
emit_catalog() {
python3 - "${CATALOG_FILE}" "${ACTION}" "${PROFILE}" "${JSON}" <<'PY'
import json
import shlex
import sys
from pathlib import Path
catalog_path = Path(sys.argv[1])
action = sys.argv[2]
profile_id = sys.argv[3]
json_mode = sys.argv[4] == "1"
data = json.loads(catalog_path.read_text(encoding="utf-8"))
profiles = [item for item in data.get("profiles", []) if isinstance(item, dict)]
index = {str(item.get("id", "")).strip(): item for item in profiles}
target = index.get(profile_id, {})
def emit_json(payload: dict) -> None:
print(json.dumps(payload, ensure_ascii=False))
if action == "summary":
payload = {
"status": "ok",
"component": "mascarade_models_tui",
"action": action,
"catalog_file": str(catalog_path),
"target_host": data.get("target_host", ""),
"default_profile": data.get("default_profile", ""),
"profile_count": len(profiles),
"profiles": [str(item.get("id", "")).strip() for item in profiles],
}
if json_mode:
emit_json(payload)
else:
print("# Mascarade model profiles\n")
print(f"- catalog: {payload['catalog_file']}")
print(f"- target_host: {payload['target_host']}")
print(f"- default_profile: {payload['default_profile']}")
print(f"- profile_count: {payload['profile_count']}")
print(f"- profiles: {', '.join(payload['profiles'])}")
elif action == "list":
payload = {
"status": "ok",
"component": "mascarade_models_tui",
"action": action,
"profiles": profiles,
}
if json_mode:
emit_json(payload)
else:
print("# Profiles\n")
for item in profiles:
print(f"- {item.get('id', '')}: {item.get('label', '')} [{item.get('category', '')}]")
elif action == "show":
if not target:
raise SystemExit("profile not found")
payload = {
"status": "ok",
"component": "mascarade_models_tui",
"action": action,
"profile": target,
}
if json_mode:
emit_json(payload)
else:
print(json.dumps(target, indent=2, ensure_ascii=False))
elif action == "env":
if not target:
raise SystemExit("profile not found")
provider_preference = ",".join(target.get("provider_preference", []))
lines = [
f'export MASCARADE_MODEL_CATALOG={shlex.quote(str(catalog_path))}',
f'export MASCARADE_OPERATOR_PROFILE={shlex.quote(str(target.get("id", "")))}',
f'export MASCARADE_OPERATOR_PROVIDER={shlex.quote(str(target.get("default_provider", "")))}',
f'export MASCARADE_OPERATOR_MODEL={shlex.quote(str(target.get("default_model", "")))}',
f'export MASCARADE_DEFAULT_MODEL={shlex.quote(str(target.get("default_model", "")))}',
f'export MASCARADE_OPERATOR_PROVIDER_PREFERENCE={shlex.quote(provider_preference)}',
f'export MASCARADE_OPERATOR_TIMEOUT={shlex.quote("45")}',
]
payload = {
"status": "ok",
"component": "mascarade_models_tui",
"action": action,
"profile": str(target.get("id", "")),
"exports": lines,
}
if json_mode:
emit_json(payload)
else:
print("\n".join(lines))
elif action == "prompt":
if not target:
raise SystemExit("profile not found")
payload = {
"status": "ok",
"component": "mascarade_models_tui",
"action": action,
"profile": str(target.get("id", "")),
"prompt": str(target.get("prompt", "")),
}
if json_mode:
emit_json(payload)
else:
print(payload["prompt"])
elif action == "agents-json":
strategy_map = {
"local-fast": "fastest",
"fallback-safe": "cheapest",
}
agents = []
for item in profiles:
profile_id = str(item.get("id", "")).strip()
label = str(item.get("label", "")).strip() or profile_id
category = str(item.get("category", "")).strip()
intended = item.get("intended_tasks")
tasks = [entry for entry in intended if isinstance(entry, str) and entry.strip()] if isinstance(intended, list) else []
summary = ", ".join(tasks[:3]) if tasks else category or "general assistance"
agents.append(
{
"name": f"kxkm-{profile_id}",
"description": f"{label} copilot for {summary}.",
"system_prompt": str(item.get("prompt", "")).strip(),
"preferred_provider": str(item.get("default_provider", "")).strip() or None,
"preferred_model": str(item.get("default_model", "")).strip() or None,
"strategy": strategy_map.get(profile_id, "best"),
"temperature": float(item.get("temperature", 0.2)),
"max_tokens": int(item.get("max_tokens", 700)),
}
)
payload = {
"status": "ok",
"component": "mascarade_models_tui",
"action": action,
"target_host": data.get("target_host", ""),
"agents": agents,
}
if json_mode:
emit_json(payload)
else:
print(json.dumps(payload, indent=2, ensure_ascii=False))
else:
raise SystemExit(f"unsupported action: {action}")
PY
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--profile)
PROFILE="${2:-}"
shift 2
;;
--json)
JSON=1
shift
;;
--days)
DAYS="${2:-14}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
if [[ -z "${ACTION}" ]]; then
if ACTION="$(choose_action_interactive)"; then
:
else
ACTION="summary"
fi
fi
if [[ "${ACTION}" == "show" || "${ACTION}" == "env" || "${ACTION}" == "prompt" ]] && [[ -z "${PROFILE}" ]]; then
if PROFILE="$(choose_profile_interactive)"; then
:
else
printf -- '--profile is required with action %s\n' "${ACTION}" >&2
exit 2
fi
fi
if ! [[ "${DAYS}" =~ ^[0-9]+$ ]]; then
printf -- '--days requires an integer\n' >&2
exit 2
fi
require_catalog
log_line "INFO" "action=${ACTION} profile=${PROFILE:-all}"
case "${ACTION}" in
summary|list|show|env|prompt|agents-json)
emit_catalog
;;
clean-logs)
find "${LOG_DIR}" -type f -name 'mascarade_models_tui_*.log' -mtime +"${DAYS}" -delete
printf 'cleaned mascarade_models_tui logs older than %s days in %s\n' "${DAYS}" "${LOG_DIR}"
;;
*)
printf 'Unknown action: %s\n' "${ACTION}" >&2
usage >&2
exit 2
;;
esac
+542
View File
@@ -0,0 +1,542 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
DEFAULT_HOST="kxkm@kxkm-ai"
DEFAULT_AGENT="kxkm-fallback-safe"
DEFAULT_LOG_DIR="$ROOT_DIR/artifacts/ops/mascarade_runtime_health"
SSH_CONNECT_TIMEOUT=6
HOST="$DEFAULT_HOST"
AGENT="$DEFAULT_AGENT"
LOG_DIR="$DEFAULT_LOG_DIR"
OUTPUT_MODE="text"
usage() {
cat <<'EOF'
Usage: mascarade_runtime_health.sh [options]
Options:
--host <user@host> Remote host to inspect (default: kxkm@kxkm-ai)
--agent <name> Agent used for low-cost smoke (default: kxkm-fallback-safe)
--log-dir <path> Artifact directory (default: artifacts/ops/mascarade_runtime_health)
--json Emit cockpit-v1 JSON to stdout
--help Show this help
EOF
}
mkdir_safe() {
mkdir -p "$1"
}
timestamp_utc() {
date -u +"%Y-%m-%dT%H:%M:%SZ"
}
timestamp_slug() {
date -u +"%Y%m%dT%H%M%SZ"
}
json_from_py() {
python3 - "$@"
}
copy_latest_artifacts() {
cp "$RUN_LOG" "$LOG_DIR/latest.log"
cp "$RUN_JSON" "$LOG_DIR/latest.json"
}
append_reason() {
DEGRADED_REASONS+=("$1")
}
append_next_step() {
NEXT_STEPS+=("$1")
}
while [[ $# -gt 0 ]]; do
case "$1" in
--host)
[[ $# -ge 2 ]] || {
echo "Missing value for --host" >&2
exit 2
}
HOST="$2"
shift 2
;;
--agent)
[[ $# -ge 2 ]] || {
echo "Missing value for --agent" >&2
exit 2
}
AGENT="$2"
shift 2
;;
--log-dir)
[[ $# -ge 2 ]] || {
echo "Missing value for --log-dir" >&2
exit 2
}
LOG_DIR="$2"
shift 2
;;
--json)
OUTPUT_MODE="json"
shift
;;
--help|-h)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
mkdir_safe "$LOG_DIR"
RUN_ID="$(timestamp_slug)"
RUN_LOG="$LOG_DIR/$RUN_ID.log"
RUN_JSON="$LOG_DIR/$RUN_ID.json"
SMOKE_CAPTURE="$LOG_DIR/$RUN_ID.smoke.json"
REMOTE_CAPTURE="$LOG_DIR/$RUN_ID.remote.txt"
ROUTING_CAPTURE="$LOG_DIR/$RUN_ID.routing.json"
MEMORY_CAPTURE="$LOG_DIR/$RUN_ID.memory.json"
touch "$RUN_LOG"
log() {
printf '[%s] %s\n' "$(timestamp_utc)" "$*" | tee -a "$RUN_LOG" >&2
}
SSH_OK="ko"
DOCKER_STATUS="unknown"
API_AGENTS_STATUS="unknown"
OLLAMA_TAGS_STATUS="unknown"
AGENT_SMOKE_STATUS="unknown"
AGENT_SMOKE_PROVIDER=""
AGENT_SMOKE_MODEL=""
ROUTING_STATUS="unknown"
MEMORY_STATUS="unknown"
TRUST_LEVEL="inferred"
RESUME_REF="kill-life:mascarade-runtime-health:${RUN_ID}:${HOST}:${AGENT}"
STATUS="ok"
CONTRACT_STATUS="ready"
declare -A CONTAINERS=(
["mascarade-api"]="unknown"
["mascarade-core"]="unknown"
["mascarade-ollama-runtime"]="unknown"
)
declare -a DEGRADED_REASONS=()
declare -a NEXT_STEPS=()
declare -a ARTIFACTS=()
ARTIFACTS+=("$RUN_LOG")
ARTIFACTS+=("$RUN_JSON")
log "Checking Mascarade runtime on $HOST"
REMOTE_SCRIPT='
set -eu
if command -v docker >/dev/null 2>&1; then
echo "docker=ok"
running="$(docker ps --format "{{.Names}}" 2>/dev/null || true)"
existing="$(docker ps -a --format "{{.Names}}" 2>/dev/null || true)"
else
echo "docker=missing"
running=""
existing=""
fi
for name in mascarade-api mascarade-core mascarade-ollama-runtime; do
if printf "%s\n" "$running" | grep -Fxq "$name"; then
echo "container:$name:running"
elif printf "%s\n" "$existing" | grep -Fxq "$name"; then
echo "container:$name:stopped"
else
echo "container:$name:missing"
fi
done
api_agents_code="$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://127.0.0.1:3100/api/agents || true)"
if [[ "$api_agents_code" == "200" || "$api_agents_code" == "401" ]] || curl -fsS --max-time 5 http://127.0.0.1:3100/health >/dev/null 2>&1; then
echo "api_agents=ok"
else
echo "api_agents=ko"
fi
if curl -fsS --max-time 5 http://127.0.0.1:11434/api/tags >/dev/null 2>&1; then
echo "ollama_tags=ok"
else
echo "ollama_tags=ko"
fi
'
if ssh -o BatchMode=yes -o ConnectTimeout="$SSH_CONNECT_TIMEOUT" "$HOST" "bash -s" >"$REMOTE_CAPTURE" 2>>"$RUN_LOG" <<<"$REMOTE_SCRIPT"; then
SSH_OK="ok"
ARTIFACTS+=("$REMOTE_CAPTURE")
while IFS= read -r line; do
case "$line" in
docker=ok)
DOCKER_STATUS="ok"
;;
docker=missing)
DOCKER_STATUS="missing"
append_reason "docker indisponible sur $HOST"
append_next_step "Installer ou rétablir Docker sur $HOST si Mascarade doit rester containerisé."
;;
container:mascarade-api:*)
CONTAINERS["mascarade-api"]="${line##*:}"
;;
container:mascarade-core:*)
CONTAINERS["mascarade-core"]="${line##*:}"
;;
container:mascarade-ollama-runtime:*)
CONTAINERS["mascarade-ollama-runtime"]="${line##*:}"
;;
api_agents=ok)
API_AGENTS_STATUS="ok"
;;
api_agents=ko)
API_AGENTS_STATUS="ko"
append_reason "l'API agents Mascarade ne répond pas sur $HOST"
append_next_step "Vérifier mascarade-api/mascarade-core et l'exposition locale 127.0.0.1:3100."
;;
ollama_tags=ok)
OLLAMA_TAGS_STATUS="ok"
;;
ollama_tags=ko)
OLLAMA_TAGS_STATUS="ko"
append_reason "le runtime Ollama ne répond pas sur $HOST"
append_next_step "Contrôler mascarade-ollama-runtime et l'endpoint 127.0.0.1:11434."
;;
esac
done <"$REMOTE_CAPTURE"
else
SSH_OK="ko"
DOCKER_STATUS="unreachable"
API_AGENTS_STATUS="unreachable"
OLLAMA_TAGS_STATUS="unreachable"
CONTAINERS["mascarade-api"]="unreachable"
CONTAINERS["mascarade-core"]="unreachable"
CONTAINERS["mascarade-ollama-runtime"]="unreachable"
append_reason "SSH indisponible vers $HOST"
append_next_step "Relancer le health-check SSH mesh avant toute action Mascarade."
fi
if [[ -f "$ROOT_DIR/tools/ops/smoke_mascarade_agents_kxkm.sh" ]]; then
if bash "$ROOT_DIR/tools/ops/smoke_mascarade_agents_kxkm.sh" --agents "$AGENT" --json >"$SMOKE_CAPTURE" 2>>"$RUN_LOG"; then
ARTIFACTS+=("$SMOKE_CAPTURE")
AGENT_SMOKE_STATUS="$(python3 - "$SMOKE_CAPTURE" <<'PY'
import json, sys
path = sys.argv[1]
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
except Exception:
print("invalid")
raise SystemExit(0)
status = data.get("status") or data.get("contract_status") or "ok"
if isinstance(status, str):
print(status)
else:
print("ok")
PY
)"
AGENT_SMOKE_PROVIDER="$(python3 - "$SMOKE_CAPTURE" <<'PY'
import json, sys
path = sys.argv[1]
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
except Exception:
print("")
raise SystemExit(0)
provider = ""
model = ""
for key in ("provider", "preferred_provider"):
if isinstance(data.get(key), str):
provider = data[key]
break
checks = data.get("checks")
if isinstance(checks, dict):
smoke = checks.get("agent_smoke")
if isinstance(smoke, dict):
provider = smoke.get("provider") or provider
model = smoke.get("model") or model
agents = data.get("agents")
if isinstance(agents, list) and agents:
first = agents[0]
if isinstance(first, dict):
provider = first.get("provider") or provider
model = first.get("model") or model
print(provider or "")
PY
)"
AGENT_SMOKE_MODEL="$(python3 - "$SMOKE_CAPTURE" <<'PY'
import json, sys
path = sys.argv[1]
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
except Exception:
print("")
raise SystemExit(0)
model = data.get("model") if isinstance(data.get("model"), str) else ""
checks = data.get("checks")
if isinstance(checks, dict):
smoke = checks.get("agent_smoke")
if isinstance(smoke, dict):
model = smoke.get("model") or model
agents = data.get("agents")
if isinstance(agents, list) and agents:
first = agents[0]
if isinstance(first, dict):
model = first.get("model") or model
print(model or "")
PY
)"
else
AGENT_SMOKE_STATUS="ko"
append_reason "le smoke agent $AGENT a échoué"
append_next_step "Consulter $(basename "$SMOKE_CAPTURE") ou relancer smoke_mascarade_agents_kxkm.sh pour diagnostic ciblé."
fi
else
AGENT_SMOKE_STATUS="missing"
append_reason "le script smoke_mascarade_agents_kxkm.sh est absent ou non exécutable"
append_next_step "Rétablir l'outillage tools/ops/smoke_mascarade_agents_kxkm.sh avant l'automatisation complète."
fi
for container_name in "mascarade-api" "mascarade-core" "mascarade-ollama-runtime"; do
state="${CONTAINERS[$container_name]}"
case "$state" in
running)
;;
*)
append_reason "conteneur $container_name en état $state"
append_next_step "Contrôler $container_name sur $HOST puis republier si nécessaire."
;;
esac
done
if [[ "$SSH_OK" != "ok" ]] || [[ "$API_AGENTS_STATUS" != "ok" ]] || [[ "$OLLAMA_TAGS_STATUS" != "ok" ]]; then
STATUS="degraded"
CONTRACT_STATUS="degraded"
fi
case "$AGENT_SMOKE_STATUS" in
ok|ready|success)
;;
*)
STATUS="degraded"
CONTRACT_STATUS="degraded"
;;
esac
if bash "$ROOT_DIR/tools/cockpit/mascarade_dispatch_mesh.sh" --action route --profile "$AGENT" --json >"$ROUTING_CAPTURE" 2>>"$RUN_LOG"; then
ROUTING_STATUS="$(python3 - "$ROUTING_CAPTURE" <<'PY'
import json, sys
from pathlib import Path
path = Path(sys.argv[1])
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
print("invalid")
raise SystemExit(0)
print(data.get("status", "unknown"))
PY
)"
ARTIFACTS+=("$ROUTING_CAPTURE")
else
ROUTING_STATUS="failed"
append_reason "le dispatch mesh Mascarade n'a pas pu etre calcule pour $AGENT"
append_next_step "Relancer bash tools/cockpit/mascarade_dispatch_mesh.sh --action route --profile $AGENT --json."
fi
if [[ "$STATUS" == "ok" && "$ROUTING_STATUS" == "ok" ]]; then
TRUST_LEVEL="verified"
elif [[ "$ROUTING_STATUS" == "ok" ]]; then
TRUST_LEVEL="bounded"
else
TRUST_LEVEL="inferred"
fi
if bash "$ROOT_DIR/tools/cockpit/write_kill_life_memory_entry.sh" \
--component "mascarade_runtime_health" \
--status "$CONTRACT_STATUS" \
--owner "Runtime-Companion" \
--decision-action "mascarade-runtime-health-check" \
--decision-reason "Mascarade runtime state is checked against the active mesh routing contract." \
--next-step "Review $RUN_JSON and continue from $RESUME_REF." \
--resume-ref "$RESUME_REF" \
--trust-level "$TRUST_LEVEL" \
--routing-file "$ROUTING_CAPTURE" \
--artifact "$RUN_LOG" \
--artifact "$RUN_JSON" \
--artifact "$SMOKE_CAPTURE" \
--json >"$MEMORY_CAPTURE" 2>>"$RUN_LOG"; then
MEMORY_STATUS="$(python3 - "$MEMORY_CAPTURE" <<'PY'
import json, sys
from pathlib import Path
path = Path(sys.argv[1])
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
print("invalid")
raise SystemExit(0)
print(data.get("status", "unknown"))
PY
)"
ARTIFACTS+=("$MEMORY_CAPTURE")
else
MEMORY_STATUS="failed"
append_reason "la memoire kill_life n'a pas pu etre ecrite pour mascarade_runtime_health"
append_next_step "Relancer bash tools/cockpit/write_kill_life_memory_entry.sh --component mascarade_runtime_health --json."
fi
RUN_AT="$(timestamp_utc)"
json_from_py \
"$RUN_AT" \
"$HOST" \
"$AGENT" \
"$STATUS" \
"$CONTRACT_STATUS" \
"$SSH_OK" \
"$DOCKER_STATUS" \
"$API_AGENTS_STATUS" \
"$OLLAMA_TAGS_STATUS" \
"$AGENT_SMOKE_STATUS" \
"$AGENT_SMOKE_PROVIDER" \
"$AGENT_SMOKE_MODEL" \
"$RUN_LOG" \
"$RUN_JSON" \
"${CONTAINERS[mascarade-api]}" \
"${CONTAINERS[mascarade-core]}" \
"${CONTAINERS[mascarade-ollama-runtime]}" \
"$ROUTING_CAPTURE" \
"$ROUTING_STATUS" \
"$MEMORY_CAPTURE" \
"$MEMORY_STATUS" \
"$TRUST_LEVEL" \
"$RESUME_REF" \
"${ARTIFACTS[@]}" \
--reasons \
"${DEGRADED_REASONS[@]}" \
--next-steps \
"${NEXT_STEPS[@]}" \
<<'PY' >"$RUN_JSON"
import json
import sys
from pathlib import Path
run_at, host, agent, status, contract_status, ssh_ok, docker_status, api_agents_status, ollama_tags_status, agent_smoke_status, agent_smoke_provider, agent_smoke_model, run_log, run_json, api_container, core_container, ollama_container, routing_capture, routing_status, memory_capture, memory_status, trust_level, resume_ref, *tail = sys.argv[1:]
def split_sections(values):
artifacts = []
reasons = []
next_steps = []
mode = "artifacts"
for value in values:
if value == "--reasons":
mode = "reasons"
continue
if value == "--next-steps":
mode = "next_steps"
continue
if mode == "artifacts":
artifacts.append(value)
elif mode == "reasons":
reasons.append(value)
else:
next_steps.append(value)
return artifacts, reasons, next_steps
artifacts, degraded_reasons, next_steps = split_sections(tail)
def load_json(path_str):
path = Path(path_str)
if not path.exists():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
routing = load_json(routing_capture)
memory = load_json(memory_capture)
memory_entry = memory.get("entry", {}) if isinstance(memory.get("entry"), dict) else {}
payload = {
"contract_version": "cockpit-v1",
"component": "mascarade-runtime-health",
"action": "health-check",
"status": status,
"contract_status": contract_status,
"checked_at": run_at,
"host": host,
"agent": agent,
"owner": "Runtime-Companion",
"decision": {
"action": "mascarade-runtime-health-check",
"reason": "Mascarade runtime state checked and projected on the active mesh routing contract."
},
"resume_ref": resume_ref,
"trust_level": trust_level,
"provider": agent_smoke_provider or "unknown",
"model": agent_smoke_model or "unknown",
"runtime_status": status,
"routing_status": routing_status,
"routing_artifact": routing_capture,
"routing": routing,
"memory_entry_status": memory_status,
"memory_entry_artifact": memory_capture,
"memory_entry": memory_entry,
"checks": {
"ssh": {"status": ssh_ok},
"docker": {"status": docker_status},
"api_agents": {"status": api_agents_status},
"ollama_tags": {"status": ollama_tags_status},
"agent_smoke": {
"status": agent_smoke_status,
"provider": agent_smoke_provider or "unknown",
"model": agent_smoke_model or "unknown",
},
"containers": {
"mascarade-api": api_container,
"mascarade-core": core_container,
"mascarade-ollama-runtime": ollama_container,
},
},
"artifacts": artifacts,
"degraded_reasons": degraded_reasons,
"next_steps": next_steps,
"log_file": run_log,
"json_file": run_json,
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
PY
copy_latest_artifacts
if [[ "$OUTPUT_MODE" == "json" ]]; then
cat "$RUN_JSON"
else
cat <<EOF
Mascarade runtime health
host: $HOST
status: $STATUS
agent smoke: $AGENT_SMOKE_STATUS (${AGENT_SMOKE_PROVIDER:-unknown}/${AGENT_SMOKE_MODEL:-unknown})
containers: api=${CONTAINERS[mascarade-api]} core=${CONTAINERS[mascarade-core]} ollama=${CONTAINERS[mascarade-ollama-runtime]}
artifacts:
- $RUN_LOG
- $RUN_JSON
EOF
if [[ ${#DEGRADED_REASONS[@]} -gt 0 ]]; then
printf 'degraded reasons:\n'
printf ' - %s\n' "${DEGRADED_REASONS[@]}"
fi
fi
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
LOG_DIR="${ROOT_DIR}/artifacts/cockpit"
mkdir -p "${LOG_DIR}"
LOG_FILE="${LOG_DIR}/mesh_dirtyset_sync_$(date '+%Y%m%d_%H%M%S').log"
MODE="apply"
JSON=0
KILL_LIFE_SRC="/Users/electron/Documents/Lelectron_rare/Github_Repos/Perso/Kill_LIFE-main"
MASCARADE_SRC="/Users/electron/Documents/Lelectron_rare/Github_Repos/Perso/mascarade-main"
CRAZY_SRC="/Users/electron/Documents/Lelectron_rare/Github_Repos/Perso/crazy_life-main"
TARGETS=(
"clems@192.168.0.120|/home/clems/Kill_LIFE-main|/home/clems/mascarade-main|/home/clems/crazy_life-main"
"kxkm@kxkm-ai|/home/kxkm/Kill_LIFE-main|/home/kxkm/mascarade-main|/home/kxkm/crazy_life-main"
"root@192.168.0.119|/root/Kill_LIFE-main|/root/mascarade-main|/root/crazy_life-main"
"cils@100.126.225.111|/Users/cils/Kill_LIFE-main|/Users/cils/mascarade-main|/Users/cils/crazy_life-main"
)
log_line() {
local level="$1"
shift
local msg="${level} $(date '+%Y-%m-%d %H:%M:%S %z') ${*}"
printf '%s\n' "${msg}" | tee -a "${LOG_FILE}" >&2
}
usage() {
cat <<'EOF'
Usage: bash tools/cockpit/mesh_dirtyset_sync.sh [--dry-run] [--json]
Synchronise uniquement les fichiers dirty des lanes mesh locales vers les lanes distantes,
et purge les artefacts Apple (`._*`, `.DS_Store`) qui polluent les dirty-sets.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run)
MODE="dry-run"
shift
;;
--json)
JSON=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
should_skip_path() {
local rel="$1"
case "${rel}" in
.DS_Store|*/.DS_Store|._*|*/._*|artifacts/cockpit/*.log|artifacts/cockpit/*.json)
return 0
;;
esac
return 1
}
collect_sync_list() {
local repo_root="$1"
local out_file="$2"
: > "${out_file}"
while IFS= read -r rel; do
[[ -n "${rel}" ]] || continue
should_skip_path "${rel}" && continue
printf '%s\n' "${rel}" >> "${out_file}"
done < <(git -C "${repo_root}" ls-files -m -o --exclude-standard)
}
collect_delete_list() {
local repo_root="$1"
local out_file="$2"
: > "${out_file}"
while IFS= read -r rel; do
[[ -n "${rel}" ]] || continue
should_skip_path "${rel}" && continue
printf '%s\n' "${rel}" >> "${out_file}"
done < <(git -C "${repo_root}" ls-files -d)
}
clean_local_apple_junk() {
local repo_root="$1"
find "${repo_root}" \( -name '._*' -o -name '.DS_Store' \) -type f -delete 2>/dev/null || true
}
clean_remote_apple_junk() {
local host="$1"
local repo_root="$2"
ssh -o BatchMode=yes -o ConnectTimeout=5 "${host}" \
"find '${repo_root}' \\( -name '._*' -o -name '.DS_Store' \\) -type f -delete 2>/dev/null || true" >/dev/null
}
sync_repo_dirtyset() {
local repo_name="$1"
local src_root="$2"
local host="$3"
local dst_root="$4"
local sync_list delete_list sync_count delete_count
sync_list="$(mktemp)"
delete_list="$(mktemp)"
collect_sync_list "${src_root}" "${sync_list}"
collect_delete_list "${src_root}" "${delete_list}"
clean_local_apple_junk "${src_root}"
sync_count="$(wc -l < "${sync_list}" | tr -d ' ')"
delete_count="$(wc -l < "${delete_list}" | tr -d ' ')"
log_line "INFO" "repo=${repo_name} target=${host} sync=${sync_count} delete=${delete_count} mode=${MODE}"
if [[ "${MODE}" == "apply" ]]; then
clean_remote_apple_junk "${host}" "${dst_root}"
if [[ "${sync_count}" != "0" ]]; then
tar -C "${src_root}" -cf - -T "${sync_list}" | \
ssh -o BatchMode=yes -o ConnectTimeout=5 "${host}" \
"mkdir -p '${dst_root}' && tar -C '${dst_root}' -xf -" >/dev/null
fi
if [[ "${delete_count}" != "0" ]]; then
while IFS= read -r rel; do
[[ -n "${rel}" ]] || continue
ssh -o BatchMode=yes -o ConnectTimeout=5 "${host}" \
"rm -f '${dst_root}/${rel}'" >/dev/null
done < "${delete_list}"
fi
clean_remote_apple_junk "${host}" "${dst_root}"
fi
rm -f "${sync_list}" "${delete_list}"
}
for target in "${TARGETS[@]}"; do
IFS='|' read -r host kill_dst masc_dst crazy_dst <<< "${target}"
sync_repo_dirtyset "Kill_LIFE" "${KILL_LIFE_SRC}" "${host}" "${kill_dst}"
sync_repo_dirtyset "mascarade" "${MASCARADE_SRC}" "${host}" "${masc_dst}"
sync_repo_dirtyset "crazy_life" "${CRAZY_SRC}" "${host}" "${crazy_dst}"
done
if [[ "${JSON}" -eq 1 ]]; then
printf '{\n'
printf ' "generated_at": "%s",\n' "$(date '+%Y-%m-%d %H:%M:%S %z')"
printf ' "mode": "%s",\n' "${MODE}"
printf ' "targets": %s,\n' "${#TARGETS[@]}"
printf ' "log_file": "%s"\n' "${LOG_FILE}"
printf '}\n'
else
log_line "INFO" "Dirty-set sync complete"
fi
+315
View File
@@ -0,0 +1,315 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
source "${ROOT_DIR}/tools/cockpit/json_contract.sh"
ARTIFACT_DIR="${ROOT_DIR}/artifacts/cockpit"
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
REPORT_DIR="${ARTIFACT_DIR}/health_reports"
mkdir -p "${REPORT_DIR}"
REGISTRY_FILE="${ROOT_DIR}/specs/contracts/machine_registry.mesh.json"
JSON=0
LOAD_PROFILE="tower-first"
RETENTION_DAYS="7"
OUTPUT_STATUS="blocked"
usage() {
cat <<'USAGE'
Usage: bash tools/cockpit/mesh_health_check.sh [options]
Options:
--load-profile <tower-first|photon-safe> Mode de précheck P2P (défaut: tower-first)
--photon-safe Alias de --load-profile photon-safe
--retention-days N Retention utilisée pour la lecture log_ops
--json Sortie JSON
-h, --help Affiche cette aide
Cette commande exécute en chaîne:
1) mesh_sync_preflight --json
2) readme_repo_coherence.sh audit
3) log_ops.sh summary --json
La sortie JSON contient le `mesh_report` et l'ordre `host_order` du préflight.
Retourne un statut consolidé: ready | degraded | blocked.
USAGE
}
parse_json_field() {
local file="$1"
local field="$2"
local raw=""
raw="$(sed -n "s/.*\"${field}\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" "${file}" | head -n1 || true)"
if [[ -z "${raw}" ]]; then
raw="$(sed -n "s/.*\"${field}\"[[:space:]]*:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p" "${file}" | head -n1 || true)"
fi
printf '%s' "${raw}"
}
normalize_log_status() {
local status="$1"
case "${status}" in
done|ok|ready)
echo "ready"
;;
degraded|warn|warning)
echo "degraded"
;;
blocked|error|fail|failed)
echo "blocked"
;;
*)
echo "unknown"
;;
esac
}
parse_json_array_string() {
local file="$1"
local field="$2"
awk -v field="\"${field}\"" '
match($0, field"[[:space:]]*:[[:space:]]*\\[") { capture=1; line=$0; next }
capture {
line = line "\n" $0
if ($0 ~ /\]/) {
gsub(/^[[:space:]]*/, "", line)
start = index(line, "[")
end = index(line, "]")
if (start > 0 && end > start) {
value = substr(line, start + 1, end - start - 1)
gsub(/"/, "", value)
gsub(/[[:space:]]/, "", value)
print value
}
exit
}
}
' "${file}"
}
json_array_count() {
local value="$1"
if [[ -z "${value}" ]]; then
printf '0'
return
fi
printf '%s' "${value}" | awk 'BEGIN{FS=","} {print NF}'
}
while [[ $# -gt 0 ]]; do
case "$1" in
--load-profile)
LOAD_PROFILE="${2:-}"
if [[ -z "${LOAD_PROFILE}" || ! "${LOAD_PROFILE}" =~ ^(tower-first|photon-safe)$ ]]; then
echo "[error] --load-profile requires tower-first|photon-safe" >&2
exit 2
fi
shift 2
;;
--photon-safe)
LOAD_PROFILE="photon-safe"
shift
;;
--retention-days)
RETENTION_DAYS="${2:-7}"
if ! [[ "${RETENTION_DAYS}" =~ ^[0-9]+$ ]]; then
echo "[error] --retention-days requires an integer" >&2
exit 2
fi
shift 2
;;
--json)
JSON=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
usage >&2
exit 2
;;
esac
done
mesh_report="${REPORT_DIR}/mesh_health_check_mesh_${TIMESTAMP}.json"
readme_report="${ROOT_DIR}/artifacts/doc/readme_repo_audit_${TIMESTAMP}.md"
log_report="${REPORT_DIR}/mesh_health_check_logops_${TIMESTAMP}.json"
registry_report="${REPORT_DIR}/mesh_health_check_registry_${TIMESTAMP}.json"
if bash "${ROOT_DIR}/tools/cockpit/machine_registry.sh" --action summary --json > "${registry_report}" 2>&1; then
registry_rc=0
else
registry_rc=$?
fi
registry_status="$(normalize_log_status "$(parse_json_field "${registry_report}" status)")"
if [[ "${registry_status}" == "unknown" ]]; then
if (( registry_rc == 0 )); then
registry_status="ready"
else
registry_status="blocked"
fi
fi
registry_target_count="$(parse_json_field "${registry_report}" target_count)"
registry_default_profile="$(parse_json_field "${registry_report}" default_profile)"
if [[ -z "${registry_target_count}" ]]; then
registry_target_count="0"
fi
if [[ -z "${registry_default_profile}" ]]; then
registry_default_profile="unknown"
fi
if bash "${ROOT_DIR}/tools/cockpit/mesh_sync_preflight.sh" --json --no-log --load-profile "${LOAD_PROFILE}" >"${mesh_report}" 2>&1; then
mesh_rc=0
else
mesh_rc=$?
fi
mesh_status="$(parse_json_field "${mesh_report}" mesh_status)"
mesh_host_order="$(parse_json_array_string "${mesh_report}" host_order)"
mesh_host_order_count="$(json_array_count "${mesh_host_order}")"
if [[ -z "${mesh_status}" ]]; then
mesh_status="blocked"
if (( mesh_rc != 0 )); then
mesh_status="blocked"
else
mesh_status="degraded"
fi
fi
if bash "${ROOT_DIR}/tools/doc/readme_repo_coherence.sh" audit --report "${readme_report}" > /dev/null 2>&1; then
readme_rc=0
else
readme_rc=$?
fi
if [[ -f "${readme_report}" ]]; then
readme_findings="$(awk '/^-[[:space:]]*Findings:/ { if (match($0, /[0-9]+/)) { print substr($0, RSTART, RLENGTH); exit } }' "${readme_report}" || true)"
if [[ -z "${readme_findings}" ]]; then
readme_findings="$(grep -m1 'Findings:' "${readme_report}" | tr -cd '0-9' || true)"
if [[ -z "${readme_findings}" ]]; then
readme_findings="0"
fi
fi
else
readme_findings="0"
readme_report=""
fi
if [[ "${readme_rc}" -eq 0 ]]; then
readme_status="ready"
else
readme_status="degraded"
fi
if [[ -n "${readme_findings}" && "${readme_findings}" != "0" ]]; then
readme_status="degraded"
fi
if bash "${ROOT_DIR}/tools/cockpit/log_ops.sh" --action summary --json --retention-days "${RETENTION_DAYS}" > "${log_report}" 2>&1; then
log_rc=0
else
log_rc=$?
fi
log_status="$(normalize_log_status "$(parse_json_field "${log_report}" status)")"
log_stale="$(parse_json_field "${log_report}" stale)"
if [[ -z "${log_stale}" ]]; then
log_stale="0"
fi
if [[ "${log_status}" == "unknown" ]]; then
log_status="degraded"
fi
if [[ "${mesh_status}" == "blocked" || "${readme_status}" == "blocked" || "${log_status}" == "blocked" || "${registry_status}" == "blocked" ]]; then
OUTPUT_STATUS="blocked"
elif [[ "${mesh_status}" == "degraded" || "${readme_status}" == "degraded" || "${log_status}" == "degraded" || "${registry_status}" == "degraded" ]]; then
OUTPUT_STATUS="degraded"
else
OUTPUT_STATUS="ready"
fi
if [[ "${mesh_status}" == "${readme_status}" && "${mesh_status}" == "${log_status}" && "${mesh_status}" == "${registry_status}" ]]; then
health_consistency="aligned"
elif [[ "${mesh_status}" == "blocked" || "${readme_status}" == "blocked" || "${log_status}" == "blocked" || "${registry_status}" == "blocked" ]]; then
health_consistency="blocked_divergence"
else
health_consistency="degraded_divergence"
fi
degraded_reasons=()
next_steps=()
artifacts=("${registry_report}" "${mesh_report}")
[[ -n "${readme_report}" ]] && artifacts+=("${readme_report}")
[[ -n "${log_report}" ]] && artifacts+=("${log_report}")
if [[ "${registry_status}" != "ready" ]]; then
degraded_reasons+=("registry-${registry_status}")
next_steps+=("bash tools/cockpit/machine_registry.sh --action summary --json")
fi
if [[ "${mesh_status}" != "ready" ]]; then
degraded_reasons+=("mesh-${mesh_status}")
next_steps+=("bash tools/cockpit/mesh_sync_preflight.sh --json --load-profile ${LOAD_PROFILE}")
fi
if [[ "${readme_status}" != "ready" ]]; then
degraded_reasons+=("readme-${readme_status}")
next_steps+=("bash tools/doc/readme_repo_coherence.sh audit")
fi
if [[ "${log_status}" != "ready" ]]; then
degraded_reasons+=("log-${log_status}")
next_steps+=("bash tools/cockpit/log_ops.sh --action purge --retention-days ${RETENTION_DAYS} --apply --json")
fi
contract_status="$(json_contract_map_status "${OUTPUT_STATUS}")"
if [[ "${JSON}" -eq 1 ]]; then
printf '{\n'
printf ' "contract_version":"cockpit-v1",\n'
printf ' "component":"mesh_health_check",\n'
printf ' "action":"summary",\n'
printf ' "contract_status":"%s",\n' "${contract_status}"
printf ' "generated_at":"%s",\n' "$(date '+%Y-%m-%d %H:%M:%S %z')"
printf ' "registry_file":"%s",\n' "${REGISTRY_FILE}"
printf ' "artifacts":%s,\n' "$(json_contract_array_from_args "${artifacts[@]}")"
printf ' "degraded_reasons":%s,\n' "$(json_contract_array_from_args "${degraded_reasons[@]}")"
printf ' "next_steps":%s,\n' "$(json_contract_array_from_args "${next_steps[@]}")"
printf ' "mesh_load_profile":"%s",\n' "${LOAD_PROFILE}"
printf ' "registry_status":"%s",\n' "${registry_status}"
printf ' "registry_target_count":%s,\n' "${registry_target_count}"
printf ' "registry_default_profile":"%s",\n' "${registry_default_profile}"
printf ' "registry_report":"%s",\n' "${registry_report}"
printf ' "mesh_status":"%s",\n' "${mesh_status}"
printf ' "mesh_host_order":"%s",\n' "${mesh_host_order:-}"
printf ' "mesh_host_order_count":%s,\n' "${mesh_host_order_count:-0}"
printf ' "mesh_status_reason_code":"%s",\n' "${mesh_rc}"
printf ' "readme_status":"%s",\n' "${readme_status}"
printf ' "readme_findings":%s,\n' "${readme_findings:-0}"
printf ' "readme_report":"%s",\n' "${readme_report}"
printf ' "log_status":"%s",\n' "${log_status}"
printf ' "log_stale":%s,\n' "${log_stale}"
printf ' "log_retention_days":%s,\n' "${RETENTION_DAYS}"
printf ' "health_consistency":"%s",\n' "${health_consistency}"
printf ' "status":"%s",\n' "${OUTPUT_STATUS}"
printf ' "mesh_report":"%s"\n' "${mesh_report}"
printf '}\n'
else
cat <<EOF_SUMMARY
mesh_health_check report:
generated_at: $(date '+%Y-%m-%d %H:%M:%S %z')
load_profile: ${LOAD_PROFILE}
machine_registry: ${registry_status} (targets=${registry_target_count}, default_profile=${registry_default_profile})
mesh_sync_preflight: ${mesh_status}
mesh_host_order: ${mesh_host_order}
readme_repo_coherence: ${readme_status} (findings=${readme_findings:-0})
log_ops: ${log_status}
consistency: ${health_consistency}
status: ${OUTPUT_STATUS}
evidence_registry: ${registry_report}
evidence mesh-host-order-count: ${mesh_host_order_count}
evidence_mesh: ${mesh_report}
evidence_readme: ${readme_report}
evidence_log: ${log_report}
EOF_SUMMARY
fi
if [[ "${OUTPUT_STATUS}" == "blocked" ]]; then
exit 1
fi
File diff suppressed because it is too large Load Diff
+380
View File
@@ -0,0 +1,380 @@
#!/bin/bash
# ============================================================================
# mistral_agents_tui.sh — TUI pour gérer les 4 agents Mistral
# Contrat: cockpit-v1
# Lot: 23 — Intégration Mistral Agents
# Date: 2026-03-21
# MAJ: 2026-03-22 — Migration Beta Conversations API (T-MA-037)
# ============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MISTRAL_API_KEY="${MISTRAL_API_KEY:-}"
MISTRAL_BASE="https://api.mistral.ai/v1"
# API mode: beta (Conversations API) ou deprecated (agents/completions)
API_MODE="${MISTRAL_API_MODE:-beta}"
# Couleurs
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'
CYAN='\033[0;36m'; BOLD='\033[1m'; DIM='\033[2m'; NC='\033[0m'
# Agents avec IDs réels (console.mistral.ai)
AGENTS=(
"ag_019d124c302375a8bf06f9ff8a99fb5f:Sentinelle:Ops/Monitoring:mistral-medium-latest:0.1"
"ag_019d124e760877359ad3ff5031179ebc:Tower:Commercial/CRM:magistral-medium-latest:0.4"
"ag_019d1251023f73258b80ac73f90458f6:Forge:Fine-tune Pipeline:codestral-latest:0.21"
"ag_019d125348eb77e880df33acbd395efa:Devstral:Code/Dev Workflow:devstral-latest:0.17"
)
# --- Fonctions utilitaires ---
log_json() {
local component="$1" action="$2" status="$3"
shift 3
local artifacts="${*:-}"
cat <<EOF
{
"contract_version": "cockpit-v1",
"component": "$component",
"action": "$action",
"status": "$status",
"api_mode": "$API_MODE",
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"artifacts": [${artifacts}]
}
EOF
}
check_api_key() {
if [ -z "$MISTRAL_API_KEY" ]; then
echo -e "${RED}ERREUR: MISTRAL_API_KEY non définie${NC}" >&2
echo " export MISTRAL_API_KEY=your_key" >&2
return 1
fi
}
# --- API Abstraction Layer ---
# Appel agent via Beta Conversations API (préféré)
_call_beta() {
local agent_id="$1" message="$2" timeout="${3:-30}"
curl -s -X POST "${MISTRAL_BASE}/conversations" \
-H "Authorization: Bearer $MISTRAL_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"agent_id\": \"${agent_id}\",
\"inputs\": [{\"role\": \"user\", \"content\": ${message}}]
}" \
--max-time "$timeout" 2>/dev/null || echo '{"error":"timeout"}'
}
# Appel agent via deprecated agents/completions (fallback)
_call_deprecated() {
local agent_id="$1" message="$2" timeout="${3:-30}"
curl -s -X POST "${MISTRAL_BASE}/agents/${agent_id}/completions" \
-H "Authorization: Bearer $MISTRAL_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"messages\":[{\"role\":\"user\",\"content\":${message}}]}" \
--max-time "$timeout" 2>/dev/null || echo '{"error":"timeout"}'
}
# Appel unifié avec fallback automatique
call_agent() {
local agent_id="$1" message="$2" timeout="${3:-30}"
if [ "$API_MODE" = "beta" ]; then
local response
response=$(_call_beta "$agent_id" "$message" "$timeout")
# Si erreur Beta, fallback vers deprecated
if echo "$response" | grep -q '"error"' || echo "$response" | grep -q '"object":"error"'; then
echo -e " ${DIM}(beta → fallback deprecated)${NC}" >&2
response=$(_call_deprecated "$agent_id" "$message" "$timeout")
fi
echo "$response"
else
_call_deprecated "$agent_id" "$message" "$timeout"
fi
}
# Extraire le contenu de la réponse (compatible beta + deprecated)
extract_content() {
python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
# Beta Conversations API format
if 'outputs' in d:
for out in d['outputs']:
if out.get('role') == 'assistant':
print(out.get('content', 'no content'))
sys.exit(0)
# Deprecated agents/completions format
choices = d.get('choices', [])
if choices:
print(choices[0].get('message', {}).get('content', 'no content'))
sys.exit(0)
# Error
print(d.get('message', d.get('error', 'no response')))
except Exception as e:
print(f'parse error: {e}')
" 2>/dev/null || echo "parse error"
}
# --- Actions ---
action_status() {
check_api_key || return 1
echo -e "${BOLD}${CYAN}=== Mistral Agents Status ===${NC}"
echo -e " Date: $(date '+%Y-%m-%d %H:%M:%S')"
echo -e " API: ${BOLD}${API_MODE}${NC} (Conversations API)"
echo ""
local ok=0 fail=0 missing=0
for entry in "${AGENTS[@]}"; do
IFS=':' read -r id name role model temp <<< "$entry"
# Ping rapide via une question minimale
local response
response=$(call_agent "$id" '"ping"' 10 2>/dev/null)
if echo "$response" | grep -q '"error"'; then
local err_msg
err_msg=$(echo "$response" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('message',d.get('error','unknown')))" 2>/dev/null || echo "unknown")
if echo "$err_msg" | grep -qi "not found\|404"; then
echo -e " ${YELLOW}${NC} ${BOLD}${name}${NC} [${model} t=${temp}] (${role}) — ${YELLOW}Not deployed${NC}"
((missing++))
else
echo -e " ${RED}${NC} ${BOLD}${name}${NC} [${model} t=${temp}] (${role}) — ${RED}Error: ${err_msg}${NC}"
((fail++))
fi
else
echo -e " ${GREEN}${NC} ${BOLD}${name}${NC} [${model} t=${temp}] (${role}) — ${GREEN}Active${NC}"
((ok++))
fi
done
echo ""
echo -e " Active: ${GREEN}${ok}${NC} | Missing: ${YELLOW}${missing}${NC} | Error: ${RED}${fail}${NC}"
echo ""
log_json "mistral-agents" "status" "$([ "$fail" -eq 0 ] && echo "ok" || echo "degraded")"
}
action_deploy() {
check_api_key || return 1
local target="${1:-all}"
echo -e "${BOLD}${CYAN}=== Deploy Mistral Agents ===${NC}"
echo -e " ${DIM}Note: Les agents sont créés via console.mistral.ai, pas via API.${NC}"
echo -e " ${DIM}IDs réels configurés dans ce script.${NC}"
echo ""
for entry in "${AGENTS[@]}"; do
IFS=':' read -r id name role model temp <<< "$entry"
if [ "$target" != "all" ] && [ "$target" != "$name" ]; then
continue
fi
echo -e " ${GREEN}${NC} ${BOLD}${name}${NC}${id}"
echo -e " Model: ${model} | Temp: ${temp} | Role: ${role}"
done
echo ""
echo -e " ${YELLOW}${NC} Pour modifier un agent, aller sur https://console.mistral.ai/build/agents"
log_json "mistral-agents" "deploy" "info"
}
action_test() {
check_api_key || return 1
echo -e "${BOLD}${CYAN}=== Smoke Test Agents (${API_MODE}) ===${NC}"
echo ""
local pass=0 total=0
for entry in "${AGENTS[@]}"; do
IFS=':' read -r id name role model temp <<< "$entry"
echo -ne " ${YELLOW}${NC} Testing ${BOLD}${name}${NC} [${model}]... "
((total++))
local msg
msg=$(python3 -c 'import json; print(json.dumps("Health check: respond OK + your role in 10 words max"))')
local response
response=$(call_agent "$id" "$msg" 30 2>/dev/null)
if echo "$response" | grep -q '"error"'; then
echo -e "${RED}FAIL${NC}"
local err
err=$(echo "$response" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('message',d.get('error','?')))" 2>/dev/null || echo "?")
echo -e " ${DIM}${err}${NC}"
else
local reply
reply=$(echo "$response" | extract_content | head -c 80)
echo -e "${GREEN}OK${NC}${reply}"
((pass++))
fi
done
echo ""
echo -e " Results: ${GREEN}${pass}/${total}${NC} passed"
echo ""
log_json "mistral-agents" "test" "$([ "$pass" -eq "$total" ] && echo "ok" || echo "degraded")"
}
action_chat() {
check_api_key || return 1
echo -e "${BOLD}${CYAN}=== Agent Chat (${API_MODE}) ===${NC}"
echo ""
echo "Agents disponibles:"
local i=1
for entry in "${AGENTS[@]}"; do
IFS=':' read -r id name role model temp <<< "$entry"
echo " ${i}) ${name} (${role}) [${model}]"
((i++))
done
read -p "Choix (1-4): " choice
local idx=$((choice - 1))
if [ "$idx" -lt 0 ] || [ "$idx" -ge "${#AGENTS[@]}" ]; then
echo -e "${RED}Choix invalide${NC}"
return 1
fi
IFS=':' read -r id name role model temp <<< "${AGENTS[$idx]}"
echo -e "Chat avec ${BOLD}${name}${NC} (${model}, API=${API_MODE}). Tapez 'quit' pour sortir."
echo ""
local conversation_id=""
while true; do
read -p "${name}> " user_input
[ "$user_input" = "quit" ] && break
local msg
msg=$(echo "$user_input" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))')
local response
response=$(call_agent "$id" "$msg" 60 2>/dev/null)
# Capturer conversation_id pour les tours suivants (Beta API)
if [ "$API_MODE" = "beta" ] && [ -z "$conversation_id" ]; then
conversation_id=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin).get('conversation_id',''))" 2>/dev/null || true)
fi
local reply
reply=$(echo "$response" | extract_content)
echo -e "${CYAN}${reply}${NC}"
echo ""
done
}
action_handoff() {
check_api_key || return 1
echo -e "${BOLD}${CYAN}=== Agent Handoff Test ===${NC}"
echo ""
# Sentinelle diagnostique → Devstral corrige
local from_id from_name to_id to_name
IFS=':' read -r from_id from_name _ _ _ <<< "${AGENTS[0]}"
IFS=':' read -r to_id to_name _ _ _ <<< "${AGENTS[3]}"
echo -e " ${YELLOW}${NC} Handoff: ${BOLD}${from_name}${NC}${BOLD}${to_name}${NC}"
local msg1
msg1=$(python3 -c 'import json; print(json.dumps("Diagnostic: le service mascarade retourne HTTP 503. Identifie la cause probable en 2 lignes."))')
echo -ne " ${from_name} analyse... "
local resp1
resp1=$(call_agent "$from_id" "$msg1" 30 2>/dev/null)
local diag
diag=$(echo "$resp1" | extract_content)
echo -e "${GREEN}OK${NC}"
echo -e " ${DIM}${diag}${NC}"
local msg2
msg2=$(python3 -c "import json; print(json.dumps('Basé sur ce diagnostic: ' + '''$diag''' + ' — Propose un fix en 3 lignes de code.'))")
echo -ne " ${to_name} corrige... "
local resp2
resp2=$(call_agent "$to_id" "$msg2" 30 2>/dev/null)
local fix
fix=$(echo "$resp2" | extract_content)
echo -e "${GREEN}OK${NC}"
echo -e " ${DIM}${fix}${NC}"
echo ""
log_json "mistral-agents" "handoff" "completed" "\"${from_name}${to_name}\""
}
# --- Menu principal ---
show_menu() {
echo -e "${BOLD}${CYAN}"
echo "╔══════════════════════════════════════════╗"
echo "║ Mistral Agents — Control Panel v2 ║"
echo "║ Lot 23 · cockpit-v1 · Beta API ║"
echo "╠══════════════════════════════════════════╣"
echo "║ 1) Status — État des 4 agents ║"
echo "║ 2) Deploy — Info agents déployés ║"
echo "║ 3) Test — Smoke test (${API_MODE}) ║"
echo "║ 4) Chat — Conversation interactive ║"
echo "║ 5) Handoff — Test Sentinelle→Devstral ║"
echo "║ 6) API Mode — Toggle beta/deprecated ║"
echo "║ q) Quit ║"
echo "╚══════════════════════════════════════════╝"
echo -e "${NC}"
}
# --- Main ---
case "${1:-menu}" in
--action)
case "${2:-status}" in
status) action_status ;;
deploy) action_deploy "${3:-all}" ;;
test) action_test ;;
chat) action_chat ;;
handoff) action_handoff ;;
*) echo "Actions: status|deploy|test|chat|handoff" ;;
esac
;;
--json)
action_status 2>/dev/null | grep -A 999 '{' | head -n -0
;;
--api-mode)
API_MODE="${2:-beta}"
echo "API mode: $API_MODE"
;;
--help|-h)
echo "Usage: $0 [--action status|deploy|test|chat|handoff] [--json] [--api-mode beta|deprecated] [--help]"
echo ""
echo " --action status Check all 4 Mistral agents"
echo " --action deploy Show deployed agents info"
echo " --action test Run smoke tests"
echo " --action chat Interactive chat with an agent"
echo " --action handoff Test Sentinelle→Devstral handoff"
echo " --json Output cockpit-v1 JSON only"
echo " --api-mode MODE Set API mode: beta (default) or deprecated"
echo ""
echo "Environment:"
echo " MISTRAL_API_KEY Required. Mistral API key."
echo " MISTRAL_API_MODE API mode: beta (default) or deprecated."
;;
*)
show_menu
read -p "Choix: " choice
case "$choice" in
1) action_status ;;
2) action_deploy ;;
3) action_test ;;
4) action_chat ;;
5) action_handoff ;;
6) if [ "$API_MODE" = "beta" ]; then API_MODE="deprecated"; else API_MODE="beta"; fi
echo -e "API mode: ${BOLD}${API_MODE}${NC}"
;;
q|Q) exit 0 ;;
*) echo "Choix invalide" ;;
esac
;;
esac
+907
View File
@@ -0,0 +1,907 @@
#!/bin/bash
# ============================================================================
# mistral_studio_tui.sh — Interface TUI pour Mistral AI Studio (toutes options)
# Contrat: cockpit-v1
# Lot: 24 — Intégration Mistral Studio Complète
# Date: 2026-03-21
#
# Couvre: Agents (Beta Conversations API), Batches, IA Documentaire, Audio,
# Fine-tune, Fichiers, Libraries (Document Library RAG), Vibe CLI, Codestral
# MAJ: 2026-03-22 — Migration Beta API + Libraries + Small 4
# ============================================================================
set -euo pipefail
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'
CYAN='\033[0;36m'; BOLD='\033[1m'; MAGENTA='\033[0;35m'; NC='\033[0m'
MISTRAL_API_KEY="${MISTRAL_API_KEY:-}"
MISTRAL_BASE="https://api.mistral.ai/v1"
CODESTRAL_BASE="https://codestral.mistral.ai/v1"
LOG_DIR="${LOG_DIR:-/tmp/mistral_studio_logs}"
LOG_FILE="${LOG_DIR}/studio_$(date +%Y%m%d_%H%M%S).log"
mkdir -p "$LOG_DIR" 2>/dev/null || true
# Agents
declare -A AGENTS=(
["sentinelle"]="ag_019d124c302375a8bf06f9ff8a99fb5f:mistral-medium-latest:ops-monitoring"
["tower"]="ag_019d124e760877359ad3ff5031179ebc:magistral-medium-latest:commercial-crm"
["forge"]="ag_019d1251023f73258b80ac73f90458f6:codestral-latest:finetune-pipeline"
["devstral"]="ag_019d125348eb77e880df33acbd395efa:devstral-latest:code-workflow"
)
# --- Logging ---
log() {
local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
echo "$msg" >> "$LOG_FILE" 2>/dev/null || true
echo -e "$msg"
}
log_json() {
local component="$1" action="$2" status="$3"
shift 3
local extra="${*:-}"
cat <<EOF
{
"contract_version": "cockpit-v1",
"component": "mistral-studio-tui",
"sub_component": "$component",
"action": "$action",
"status": "$status",
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"log_file": "$LOG_FILE"${extra:+,
$extra}
}
EOF
}
check_api_key() {
if [ -z "$MISTRAL_API_KEY" ]; then
echo -e "${RED}✗ MISTRAL_API_KEY non définie${NC}"
return 1
fi
}
api_call() {
local method="$1" endpoint="$2" data="${3:-}"
local base="${4:-$MISTRAL_BASE}"
local args=(-s -w "\n%{http_code}" --max-time 30
-H "Authorization: Bearer $MISTRAL_API_KEY"
-H "Content-Type: application/json")
if [ "$method" = "POST" ] && [ -n "$data" ]; then
args+=(-X POST -d "$data")
elif [ "$method" = "DELETE" ]; then
args+=(-X DELETE)
fi
curl "${args[@]}" "${base}${endpoint}" 2>/dev/null
}
# ============================================================================
# 1. AGENTS
# ============================================================================
# API mode: "beta" uses /conversations/completions, "deprecated" uses /agents/completions
API_MODE="${API_MODE:-beta}"
# Helper: call agent with beta fallback
call_agent_api() {
local agent_id="$1" payload="$2"
local response http_code
if [ "$API_MODE" = "beta" ]; then
# Try Beta Conversations API first
response=$(api_call POST "/conversations/completions" \
"$(echo "$payload" | python3 -c "
import sys, json
d = json.load(sys.stdin)
d['agent_id'] = '${agent_id}'
print(json.dumps(d))
" 2>/dev/null)")
http_code=$(echo "$response" | tail -1)
if [ "$http_code" = "200" ]; then
echo "$response"
return 0
fi
# Fallback to deprecated
log "WARN: Beta API failed (HTTP $http_code), falling back to deprecated"
fi
# Deprecated API
response=$(api_call POST "/agents/completions" \
"$(echo "$payload" | python3 -c "
import sys, json
d = json.load(sys.stdin)
d['agent_id'] = '${agent_id}'
print(json.dumps(d))
" 2>/dev/null)")
echo "$response"
}
action_agents_status() {
echo -e "\n${BOLD}${CYAN}[Agents — Status (API: $API_MODE)]${NC}"
check_api_key || return 1
for name in sentinelle tower forge devstral; do
IFS=':' read -r id model role <<< "${AGENTS[$name]}"
echo -e " ${BOLD}$name${NC} ($role)"
echo -e " Model: ${MAGENTA}$model${NC}"
echo -e " ID: $id"
local response
response=$(call_agent_api "$id" '{"messages":[{"role":"user","content":"ping"}],"max_tokens":5}')
local http_code
http_code=$(echo "$response" | tail -1)
if [ "$http_code" = "200" ]; then
echo -e " Status: ${GREEN}✓ ONLINE${NC}"
else
echo -e " Status: ${RED}✗ HTTP $http_code${NC}"
fi
done
log "agents_status completed (api=$API_MODE)"
}
action_agents_chat() {
local agent="${1:-sentinelle}"
echo -e "\n${BOLD}${CYAN}[Agent Chat — $agent (API: $API_MODE)]${NC}"
check_api_key || return 1
IFS=':' read -r id model role <<< "${AGENTS[$agent]}"
echo -e " Agent: $agent ($model, $role)"
echo -e " Tapez 'quit' pour quitter\n"
local conv_id=""
while true; do
echo -ne "${GREEN}> ${NC}"
read -r user_input
[ "$user_input" = "quit" ] && break
local payload
payload=$(python3 -c "
import json
d = {'messages': [{'role': 'user', 'content': '''$user_input'''}], 'max_tokens': 500}
conv_id = '''$conv_id'''
if conv_id:
d['conversation_id'] = conv_id
print(json.dumps(d))
" 2>/dev/null)
local response
response=$(call_agent_api "$id" "$payload")
local http_code body
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [ "$http_code" = "200" ]; then
local content
content=$(echo "$body" | python3 -c "
import sys, json
d = json.load(sys.stdin)
# Capture conversation_id for stateful Beta API
cid = d.get('conversation_id', '')
if cid:
print('CONV_ID:' + cid, file=sys.stderr)
print(d.get('choices',[{}])[0].get('message',{}).get('content','no response'))
" 2>/dev/null 2> >(grep 'CONV_ID:' | head -1 | sed 's/CONV_ID://'))
# Capture conversation_id from stderr for stateful chat
local new_conv_id
new_conv_id=$(echo "$body" | python3 -c "import sys,json;print(json.load(sys.stdin).get('conversation_id',''))" 2>/dev/null || echo "")
[ -n "$new_conv_id" ] && conv_id="$new_conv_id"
echo -e "\n${CYAN}$agent${NC}: $content\n"
else
echo -e "\n${RED}Error HTTP $http_code${NC}\n"
fi
log "chat:$agent user='$user_input' http=$http_code conv=$conv_id"
done
}
# ============================================================================
# 2. FICHIERS
# ============================================================================
action_files_list() {
echo -e "\n${BOLD}${CYAN}[Fichiers — Liste]${NC}"
check_api_key || return 1
local response
response=$(api_call GET "/files")
local http_code body
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [ "$http_code" = "200" ]; then
python3 -c "
import json, sys
d = json.loads('''$body''')
files = d.get('data', [])
if not files:
print(' (aucun fichier)')
else:
for f in files:
print(f' {f[\"id\"]:30} {f.get(\"filename\",\"?\"):40} {f.get(\"bytes\",0):>10} bytes {f.get(\"purpose\",\"?\")}')
print(f'\n Total: {len(files)} fichiers')
" 2>/dev/null
else
echo -e " ${RED}Erreur HTTP $http_code${NC}"
fi
log "files_list http=$http_code"
}
action_files_upload() {
local filepath="$1" purpose="${2:-fine-tune}"
echo -e "\n${BOLD}${CYAN}[Fichiers — Upload]${NC}"
check_api_key || return 1
if [ ! -f "$filepath" ]; then
echo -e " ${RED}Fichier non trouvé: $filepath${NC}"
return 1
fi
echo -e " Uploading: $(basename "$filepath") (purpose: $purpose)"
local response
response=$(curl -s -w "\n%{http_code}" --max-time 120 \
-H "Authorization: Bearer $MISTRAL_API_KEY" \
-F "file=@$filepath" \
-F "purpose=$purpose" \
"${MISTRAL_BASE}/files" 2>/dev/null)
local http_code body
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [ "$http_code" = "200" ]; then
local file_id
file_id=$(echo "$body" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id','?'))" 2>/dev/null)
echo -e " ${GREEN}✓ Upload OK${NC} — ID: $file_id"
log_json "files" "upload" "success" "\"file_id\": \"$file_id\", \"filename\": \"$(basename "$filepath")\""
else
echo -e " ${RED}✗ Upload failed HTTP $http_code${NC}"
echo " $body"
fi
log "files_upload file=$(basename "$filepath") purpose=$purpose http=$http_code"
}
# ============================================================================
# 3. FINE-TUNE
# ============================================================================
action_finetune_list() {
echo -e "\n${BOLD}${CYAN}[Fine-tune — Jobs]${NC}"
check_api_key || return 1
local response
response=$(api_call GET "/fine_tuning/jobs")
local http_code body
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [ "$http_code" = "200" ]; then
python3 -c "
import json, sys
d = json.loads('''$body''')
jobs = d.get('data', [])
if not jobs:
print(' (aucun job)')
else:
for j in jobs:
status = j.get('status','?')
color = '32' if status == 'SUCCEEDED' else '33' if status == 'RUNNING' else '31'
print(f' \033[{color}m●\033[0m {j.get(\"id\",\"?\"):30} {j.get(\"fine_tuned_model\",\"?\"):30} {status}')
print(f'\n Total: {len(jobs)} jobs')
" 2>/dev/null
else
echo -e " ${RED}Erreur HTTP $http_code${NC}"
fi
log "finetune_list http=$http_code"
}
action_finetune_create() {
local model="$1" training_file="$2"
local suffix="${3:-v1}"
echo -e "\n${BOLD}${CYAN}[Fine-tune — Create Job]${NC}"
check_api_key || return 1
echo -e " Model: $model"
echo -e " Training file: $training_file"
echo -e " Suffix: $suffix"
local payload="{
\"model\": \"$model\",
\"training_files\": [\"$training_file\"],
\"suffix\": \"$suffix\",
\"hyperparameters\": {
\"training_steps\": 100,
\"learning_rate\": 1e-5
}
}"
local response
response=$(api_call POST "/fine_tuning/jobs" "$payload")
local http_code body
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [ "$http_code" = "200" ]; then
local job_id
job_id=$(echo "$body" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id','?'))" 2>/dev/null)
echo -e " ${GREEN}✓ Job créé${NC} — ID: $job_id"
log_json "finetune" "create" "success" "\"job_id\": \"$job_id\""
else
echo -e " ${RED}✗ Création failed HTTP $http_code${NC}"
echo " $body"
fi
log "finetune_create model=$model file=$training_file http=$http_code"
}
action_finetune_models() {
echo -e "\n${BOLD}${CYAN}[Fine-tune — Modèles personnalisés]${NC}"
check_api_key || return 1
local response
response=$(api_call GET "/models")
local http_code body
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [ "$http_code" = "200" ]; then
python3 -c "
import json, sys
d = json.loads(sys.stdin.read())
models = [m for m in d.get('data', []) if m.get('id','').startswith('ft:')]
if not models:
print(' (aucun modèle fine-tuné)')
else:
for m in models:
print(f' {m[\"id\"]:40} owner={m.get(\"owned_by\",\"?\")}')
print(f'\n Fine-tuned: {len(models)}')
" <<< "$body" 2>/dev/null
else
echo -e " ${RED}Erreur HTTP $http_code${NC}"
fi
log "finetune_models http=$http_code"
}
# ============================================================================
# 4. BATCHES
# ============================================================================
action_batches_list() {
echo -e "\n${BOLD}${CYAN}[Batches — Liste]${NC}"
check_api_key || return 1
local response
response=$(api_call GET "/batches")
local http_code body
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [ "$http_code" = "200" ]; then
python3 -c "
import json, sys
d = json.loads('''$body''')
batches = d.get('data', [])
if not batches:
print(' (aucun batch)')
else:
for b in batches:
print(f' {b.get(\"id\",\"?\"):30} {b.get(\"status\",\"?\"):15} {b.get(\"model\",\"?\")}')
print(f'\n Total: {len(batches)} batches')
" 2>/dev/null
else
echo -e " ${RED}Erreur HTTP $http_code${NC}"
fi
log "batches_list http=$http_code"
}
# ============================================================================
# 5. IA DOCUMENTAIRE (OCR)
# ============================================================================
action_docai_ocr() {
local filepath="$1"
echo -e "\n${BOLD}${CYAN}[IA Documentaire — OCR]${NC}"
check_api_key || return 1
if [ ! -f "$filepath" ]; then
echo -e " ${RED}Fichier non trouvé: $filepath${NC}"
return 1
fi
echo -e " Processing: $(basename "$filepath")"
# Upload file first, then use document AI
local mime_type="application/pdf"
[[ "$filepath" == *.png ]] && mime_type="image/png"
[[ "$filepath" == *.jpg ]] && mime_type="image/jpeg"
local response
response=$(curl -s -w "\n%{http_code}" --max-time 120 \
-H "Authorization: Bearer $MISTRAL_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"model\": \"mistral-ocr-latest\",
\"document\": {
\"type\": \"document_url\",
\"document_url\": \"data:${mime_type};base64,$(base64 -w0 "$filepath")\"
}
}" \
"${MISTRAL_BASE}/ocr" 2>/dev/null)
local http_code body
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [ "$http_code" = "200" ]; then
echo -e " ${GREEN}✓ OCR OK${NC}"
python3 -c "
import json, sys
d = json.loads(sys.stdin.read())
pages = d.get('pages', [])
for p in pages:
print(f' --- Page {p.get(\"index\",\"?\")} ---')
text = p.get('markdown', p.get('text', ''))
print(text[:500])
if len(text) > 500:
print(' ...[truncated]')
" <<< "$body" 2>/dev/null
else
echo -e " ${RED}✗ OCR failed HTTP $http_code${NC}"
fi
log "docai_ocr file=$(basename "$filepath") http=$http_code"
}
# ============================================================================
# 6. AUDIO (STT)
# ============================================================================
action_audio_transcribe() {
local filepath="$1"
echo -e "\n${BOLD}${CYAN}[Audio — Transcription]${NC}"
check_api_key || return 1
if [ ! -f "$filepath" ]; then
echo -e " ${RED}Fichier non trouvé: $filepath${NC}"
return 1
fi
echo -e " Transcribing: $(basename "$filepath")"
local response
response=$(curl -s -w "\n%{http_code}" --max-time 300 \
-H "Authorization: Bearer $MISTRAL_API_KEY" \
-F "file=@$filepath" \
-F "model=mistral-stt-latest" \
"${MISTRAL_BASE}/audio/transcriptions" 2>/dev/null)
local http_code body
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [ "$http_code" = "200" ]; then
echo -e " ${GREEN}✓ Transcription OK${NC}"
python3 -c "
import json, sys
d = json.loads(sys.stdin.read())
print(d.get('text', 'no text'))
" <<< "$body" 2>/dev/null
else
echo -e " ${RED}✗ Transcription failed HTTP $http_code${NC}"
fi
log "audio_transcribe file=$(basename "$filepath") http=$http_code"
}
# ============================================================================
# 7. CODESTRAL
# ============================================================================
action_codestral_complete() {
local prompt="$1"
local suffix="${2:-}"
echo -e "\n${BOLD}${CYAN}[Codestral — FIM Completion]${NC}"
local codestral_key="${CODESTRAL_API_KEY:-$MISTRAL_API_KEY}"
local payload="{\"model\":\"codestral-latest\",\"prompt\":\"$prompt\",\"suffix\":\"$suffix\",\"max_tokens\":200}"
local response
response=$(curl -s -w "\n%{http_code}" --max-time 30 \
-H "Authorization: Bearer $codestral_key" \
-H "Content-Type: application/json" \
-d "$payload" \
"${CODESTRAL_BASE}/fim/completions" 2>/dev/null)
local http_code body
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [ "$http_code" = "200" ]; then
echo -e " ${GREEN}✓ Completion OK${NC}"
python3 -c "
import json, sys
d = json.loads(sys.stdin.read())
choices = d.get('choices', [])
if choices:
print(choices[0].get('message',{}).get('content', choices[0].get('text','')))
" <<< "$body" 2>/dev/null
else
echo -e " ${RED}✗ Codestral failed HTTP $http_code${NC}"
fi
log "codestral_complete http=$http_code"
}
# ============================================================================
# 8. CONVERSATIONS (Beta API)
# ============================================================================
action_conversations_list() {
echo -e "\n${BOLD}${CYAN}[Conversations — Liste (Beta)]${NC}"
check_api_key || return 1
local response
response=$(api_call GET "/conversations")
local http_code body
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [ "$http_code" = "200" ]; then
python3 -c "
import json, sys
d = json.loads(sys.stdin.read())
convs = d.get('data', [])
if not convs:
print(' (aucune conversation)')
else:
for c in convs:
print(f' {c.get(\"id\",\"?\"):36} agent={c.get(\"agent_id\",\"?\"):36} created={c.get(\"created_at\",\"?\")}')
print(f'\n Total: {len(convs)} conversations')
" <<< "$body" 2>/dev/null
else
echo -e " ${RED}Erreur HTTP $http_code${NC} (Beta API may not be available yet)"
fi
log "conversations_list http=$http_code"
}
action_conversations_create() {
local agent="${1:-sentinelle}"
echo -e "\n${BOLD}${CYAN}[Conversations — Créer (Beta)]${NC}"
check_api_key || return 1
IFS=':' read -r id model role <<< "${AGENTS[$agent]}"
echo -e " Agent: $agent ($id)"
local response
response=$(api_call POST "/conversations" "{\"agent_id\":\"$id\"}")
local http_code body
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [ "$http_code" = "200" ]; then
local conv_id
conv_id=$(echo "$body" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id','?'))" 2>/dev/null)
echo -e " ${GREEN}✓ Conversation créée${NC} — ID: $conv_id"
log_json "conversations" "create" "success" "\"conversation_id\": \"$conv_id\", \"agent\": \"$agent\""
else
echo -e " ${RED}✗ Création failed HTTP $http_code${NC} (Beta API may not be available yet)"
fi
log "conversations_create agent=$agent http=$http_code"
}
# ============================================================================
# 9. LIBRARIES (Document Library RAG — Beta)
# ============================================================================
action_libraries_list() {
echo -e "\n${BOLD}${CYAN}[Libraries — Liste (Beta)]${NC}"
check_api_key || return 1
local response
response=$(api_call GET "/libraries")
local http_code body
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [ "$http_code" = "200" ]; then
python3 -c "
import json, sys
d = json.loads(sys.stdin.read())
libs = d.get('data', [])
if not libs:
print(' (aucune library)')
else:
for l in libs:
print(f' {l.get(\"id\",\"?\"):36} {l.get(\"name\",\"?\"):30} docs={l.get(\"document_count\",0)}')
print(f'\n Total: {len(libs)} libraries')
" <<< "$body" 2>/dev/null
else
echo -e " ${RED}Erreur HTTP $http_code${NC} (Beta API may not be available yet)"
fi
log "libraries_list http=$http_code"
}
action_libraries_create() {
local name="$1" description="${2:-}"
echo -e "\n${BOLD}${CYAN}[Libraries — Créer (Beta)]${NC}"
check_api_key || return 1
echo -e " Name: $name"
[ -n "$description" ] && echo -e " Description: $description"
local payload
payload=$(python3 -c "
import json
d = {'name': '''$name'''}
desc = '''$description'''
if desc:
d['description'] = desc
print(json.dumps(d))
" 2>/dev/null)
local response
response=$(api_call POST "/libraries" "$payload")
local http_code body
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [ "$http_code" = "200" ]; then
local lib_id
lib_id=$(echo "$body" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id','?'))" 2>/dev/null)
echo -e " ${GREEN}✓ Library créée${NC} — ID: $lib_id"
log_json "libraries" "create" "success" "\"library_id\": \"$lib_id\", \"name\": \"$name\""
else
echo -e " ${RED}✗ Création failed HTTP $http_code${NC}"
fi
log "libraries_create name=$name http=$http_code"
}
action_libraries_add_doc() {
local library_id="$1" file_id="$2"
echo -e "\n${BOLD}${CYAN}[Libraries — Ajouter Document]${NC}"
check_api_key || return 1
local response
response=$(api_call POST "/libraries/$library_id/documents" "{\"file_id\":\"$file_id\"}")
local http_code body
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [ "$http_code" = "200" ]; then
echo -e " ${GREEN}✓ Document ajouté${NC}"
log_json "libraries" "add_document" "success" "\"library_id\": \"$library_id\", \"file_id\": \"$file_id\""
else
echo -e " ${RED}✗ Ajout failed HTTP $http_code${NC}"
fi
log "libraries_add_doc lib=$library_id file=$file_id http=$http_code"
}
# ============================================================================
# 10. MODELS CATALOG
# ============================================================================
action_models_list() {
echo -e "\n${BOLD}${CYAN}[Modèles — Catalogue]${NC}"
check_api_key || return 1
local response
response=$(api_call GET "/models")
local http_code body
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [ "$http_code" = "200" ]; then
python3 -c "
import json, sys
d = json.loads(sys.stdin.read())
models = sorted(d.get('data', []), key=lambda m: m.get('id', ''))
ft_models = [m for m in models if m.get('id','').startswith('ft:')]
base_models = [m for m in models if not m.get('id','').startswith('ft:')]
print(' === Base Models ===')
for m in base_models:
print(f' {m[\"id\"]:45} owner={m.get(\"owned_by\",\"?\")}')
print(f' Total: {len(base_models)} base models')
if ft_models:
print('\n === Fine-tuned Models ===')
for m in ft_models:
print(f' {m[\"id\"]:45} owner={m.get(\"owned_by\",\"?\")}')
print(f' Total: {len(ft_models)} fine-tuned')
" <<< "$body" 2>/dev/null
else
echo -e " ${RED}Erreur HTTP $http_code${NC}"
fi
log "models_list http=$http_code"
}
# ============================================================================
# 11. LOGS
# ============================================================================
action_logs_view() {
echo -e "\n${BOLD}${CYAN}[Logs — Derniers]${NC}"
if [ -f "$LOG_FILE" ]; then
tail -20 "$LOG_FILE"
else
echo " (aucun log)"
fi
echo -e "\n Log dir: $LOG_DIR"
echo -e " Fichiers: $(ls "$LOG_DIR"/*.log 2>/dev/null | wc -l) logs"
}
action_logs_purge() {
echo -e "\n${BOLD}${CYAN}[Logs — Purge]${NC}"
local count
count=$(find "$LOG_DIR" -name "*.log" -mtime +7 2>/dev/null | wc -l)
find "$LOG_DIR" -name "*.log" -mtime +7 -delete 2>/dev/null || true
echo -e " ${GREEN}$count logs supprimés (>7 jours)${NC}"
log "logs_purge deleted=$count"
}
# ============================================================================
# MENU PRINCIPAL
# ============================================================================
show_menu() {
echo ""
echo -e "${BOLD}${CYAN}╔══════════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${CYAN}║ Mistral AI Studio — TUI Cockpit (Lot 24) ║${NC}"
echo -e "${BOLD}${CYAN}$(date '+%Y-%m-%d %H:%M') API: $API_MODE${NC}"
echo -e "${BOLD}${CYAN}╚══════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${BOLD} Agents${NC}"
echo " 1) Agents — status 2) Agents — chat"
echo ""
echo -e "${BOLD} Conversations (Beta)${NC}"
echo " 3) Conversations — lister 4) Conversation — créer"
echo ""
echo -e "${BOLD} Fichiers & Libraries${NC}"
echo " 5) Fichiers — lister 6) Fichiers — uploader"
echo " 7) Libraries — lister 8) Library — créer"
echo " 9) Library — ajouter doc"
echo ""
echo -e "${BOLD} Fine-tune${NC}"
echo " 10) Jobs — lister 11) Job — créer"
echo " 12) Modèles personnalisés 13) Catalogue complet"
echo ""
echo -e "${BOLD} Batches${NC}"
echo " 14) Batches — lister"
echo ""
echo -e "${BOLD} IA${NC}"
echo " 15) IA Documentaire — OCR 16) Audio — transcrire"
echo " 17) Codestral — complétion"
echo ""
echo -e "${BOLD} Maintenance${NC}"
echo " 18) Logs — voir 19) Logs — purger"
echo " 20) Health check complet"
echo ""
echo " q) Quitter"
echo ""
}
action_health_full() {
echo -e "\n${BOLD}${CYAN}[Health Check Complet — Mistral Studio (API: $API_MODE)]${NC}"
check_api_key || return 1
action_agents_status
action_files_list
action_finetune_list
action_finetune_models
action_batches_list
action_libraries_list
action_conversations_list
echo ""
log_json "studio" "full-health" "completed" "\"api_mode\": \"$API_MODE\""
}
# ============================================================================
# MAIN
# ============================================================================
# CLI mode
case "${1:-}" in
--agents-status) action_agents_status ;;
--agents-chat) action_agents_chat "${2:-sentinelle}" ;;
--conversations-list) action_conversations_list ;;
--conversations-create) action_conversations_create "${2:-sentinelle}" ;;
--files-list) action_files_list ;;
--files-upload) action_files_upload "${2:?filepath required}" "${3:-fine-tune}" ;;
--libraries-list) action_libraries_list ;;
--libraries-create) action_libraries_create "${2:?name required}" "${3:-}" ;;
--libraries-add-doc) action_libraries_add_doc "${2:?library_id required}" "${3:?file_id required}" ;;
--finetune-list) action_finetune_list ;;
--finetune-create) action_finetune_create "${2:?model}" "${3:?training_file}" "${4:-v1}" ;;
--finetune-models) action_finetune_models ;;
--models-list) action_models_list ;;
--batches-list) action_batches_list ;;
--ocr) action_docai_ocr "${2:?filepath required}" ;;
--transcribe) action_audio_transcribe "${2:?filepath required}" ;;
--codestral) action_codestral_complete "${2:?prompt}" "${3:-}" ;;
--health) action_health_full ;;
--logs) action_logs_view ;;
--purge-logs) action_logs_purge ;;
--json) action_health_full 2>/dev/null | grep -A 999 '{' ;;
--api-mode) API_MODE="${2:-beta}"; shift ;;&
--help|-h)
echo "Usage: $0 [action] [args]"
echo ""
echo " Agents:"
echo " --agents-status Status de tous les agents"
echo " --agents-chat [name] Chat interactif avec un agent"
echo ""
echo " Conversations (Beta API):"
echo " --conversations-list Lister les conversations"
echo " --conversations-create [agent] Créer une conversation"
echo ""
echo " Fichiers:"
echo " --files-list Lister les fichiers"
echo " --files-upload FILE [purpose] Uploader un fichier"
echo ""
echo " Libraries (Beta — Document RAG):"
echo " --libraries-list Lister les document libraries"
echo " --libraries-create NAME [desc] Créer une library"
echo " --libraries-add-doc LIB FILE Ajouter un document à une library"
echo ""
echo " Fine-tune:"
echo " --finetune-list Lister les jobs fine-tune"
echo " --finetune-create MODEL FILE Créer un job fine-tune"
echo " --finetune-models Lister les modèles fine-tunés"
echo " --models-list Catalogue complet des modèles"
echo ""
echo " Batches / IA:"
echo " --batches-list Lister les batches"
echo " --ocr FILE OCR d'un document"
echo " --transcribe FILE Transcrire un audio"
echo " --codestral PROMPT Complétion Codestral"
echo ""
echo " Maintenance:"
echo " --health Health check complet"
echo " --logs Voir les logs"
echo " --purge-logs Purger logs >7j"
echo ""
echo " Options globales:"
echo " --api-mode beta|deprecated Mode API (défaut: beta)"
echo ""
;;
"")
# Mode interactif
while true; do
show_menu
echo -ne "${GREEN}Choix> ${NC}"
read -r choice
case "$choice" in
1) action_agents_status ;;
2) echo -ne "Agent (sentinelle/tower/forge/devstral): "; read -r a; action_agents_chat "$a" ;;
3) action_conversations_list ;;
4) echo -ne "Agent (sentinelle/tower/forge/devstral): "; read -r a; action_conversations_create "$a" ;;
5) action_files_list ;;
6) echo -ne "Chemin fichier: "; read -r f; echo -ne "Purpose (fine-tune/batch/ocr): "; read -r p; action_files_upload "$f" "${p:-fine-tune}" ;;
7) action_libraries_list ;;
8) echo -ne "Nom library: "; read -r n; echo -ne "Description: "; read -r d; action_libraries_create "$n" "$d" ;;
9) echo -ne "Library ID: "; read -r lid; echo -ne "File ID: "; read -r fid; action_libraries_add_doc "$lid" "$fid" ;;
10) action_finetune_list ;;
11) echo -ne "Modèle (open-mistral-7b/mistral-small-latest): "; read -r m; echo -ne "Training file ID: "; read -r tf; action_finetune_create "$m" "$tf" ;;
12) action_finetune_models ;;
13) action_models_list ;;
14) action_batches_list ;;
15) echo -ne "Chemin document: "; read -r f; action_docai_ocr "$f" ;;
16) echo -ne "Chemin audio: "; read -r f; action_audio_transcribe "$f" ;;
17) echo -ne "Prompt code: "; read -r p; action_codestral_complete "$p" ;;
18) action_logs_view ;;
19) action_logs_purge ;;
20) action_health_full ;;
q|Q) echo "Au revoir."; exit 0 ;;
*) echo -e "${RED}Choix invalide${NC}" ;;
esac
done
;;
*)
echo "Action inconnue: $1 — utiliser --help"
exit 1
;;
esac
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env bash
set -euo pipefail
# mistral_workspace_guard.sh
# Guardrail for the single authorized workspace layout used by Kill_LIFE Mistral lots.
# Contract: cockpit-v1
# Date: 2026-03-22
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
STAMP="$(date +%Y%m%d_%H%M%S)"
ARTIFACTS_DIR="${ROOT_DIR}/artifacts/cockpit/mistral_workspace_guard"
mkdir -p "${ARTIFACTS_DIR}"
ACTION="status"
JSON_MODE=0
KILL_LIFE_ROOT="/Users/electron/Documents/Lelectron_rare/Kill_LIFE"
MASCARADE_ROOT="/Users/electron/Documents/Projets/mascarade"
FORBIDDEN_ROOT="/Users/electron/Documents/Lelectron_rare/Github_Repos/Perso/mascarade-main"
usage() {
cat <<'EOF'
Usage: mistral_workspace_guard.sh [--action status|assert|paths] [--json]
Actions:
status Show current workspace policy state
assert Exit non-zero if the policy is violated
paths Print the canonical paths only
Options:
--json Emit cockpit-v1 JSON
--help Show this help
EOF
}
json_escape() {
python3 - "$1" <<'PY'
import json
import sys
print(json.dumps(sys.argv[1]))
PY
}
path_exists_json() {
local path="$1"
if [[ -e "$path" ]]; then
printf 'true'
else
printf 'false'
fi
}
build_json() {
local status="$1"
local action="$2"
local reason="$3"
local required_kill_life_exists required_mascarade_exists forbidden_exists
required_kill_life_exists="$(path_exists_json "$KILL_LIFE_ROOT")"
required_mascarade_exists="$(path_exists_json "$MASCARADE_ROOT")"
forbidden_exists="$(path_exists_json "$FORBIDDEN_ROOT")"
cat <<EOF
{
"contract_version": "cockpit-v1",
"component": "mistral-workspace-guard",
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"action": "${action}",
"status": "${status}",
"reason": $(json_escape "${reason}"),
"kill_life_root": $(json_escape "${KILL_LIFE_ROOT}"),
"mascarade_root": $(json_escape "${MASCARADE_ROOT}"),
"forbidden_root": $(json_escape "${FORBIDDEN_ROOT}"),
"kill_life_exists": ${required_kill_life_exists},
"mascarade_exists": ${required_mascarade_exists},
"forbidden_exists": ${forbidden_exists},
"policy": {
"tracking_root": $(json_escape "${KILL_LIFE_ROOT}"),
"active_mascarade_root": $(json_escape "${MASCARADE_ROOT}"),
"forbidden_copy": $(json_escape "${FORBIDDEN_ROOT}")
}
}
EOF
}
status_report() {
local status="ok"
local reason="single-workspace policy respected"
if [[ ! -d "$KILL_LIFE_ROOT" ]]; then
status="error"
reason="Kill_LIFE root missing"
elif [[ ! -d "$MASCARADE_ROOT" ]]; then
status="error"
reason="Mascarade root missing"
elif [[ -e "$FORBIDDEN_ROOT" ]]; then
status="degraded"
reason="forbidden duplicate present; do not use it"
fi
if [[ "$JSON_MODE" -eq 1 ]]; then
build_json "$status" "$ACTION" "$reason"
else
printf 'Mistral workspace guard\n'
printf 'status: %s\n' "$status"
printf 'reason: %s\n' "$reason"
printf 'tracking_root: %s\n' "$KILL_LIFE_ROOT"
printf 'active_mascarade_root: %s\n' "$MASCARADE_ROOT"
printf 'forbidden_copy: %s\n' "$FORBIDDEN_ROOT"
fi
local latest_file="${ARTIFACTS_DIR}/latest.json"
build_json "$status" "$ACTION" "$reason" > "${ARTIFACTS_DIR}/mistral_workspace_guard_${STAMP}.json"
build_json "$status" "$ACTION" "$reason" > "$latest_file"
[[ "$status" == "error" ]] && return 1
[[ "$ACTION" == "assert" && "$status" != "ok" ]] && return 1
return 0
}
paths_report() {
if [[ "$JSON_MODE" -eq 1 ]]; then
build_json "ok" "$ACTION" "canonical paths only"
else
printf '%s\n' "$KILL_LIFE_ROOT"
printf '%s\n' "$MASCARADE_ROOT"
printf '%s\n' "$FORBIDDEN_ROOT"
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--json)
JSON_MODE=1
shift
;;
--help|-h)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
case "$ACTION" in
status|assert)
status_report
;;
paths)
paths_report
;;
*)
printf 'Unknown action: %s\n' "$ACTION" >&2
usage >&2
exit 2
;;
esac
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
set -euo pipefail
# ops_erp_registry_tui.sh
# Read the ERP / L'electronrare Ops registry for Kill_LIFE.
# Contract: cockpit-v1
# Date: 2026-03-22
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
REGISTRY_FILE="${REGISTRY_FILE:-${ROOT_DIR}/specs/contracts/ops_kill_life_erp_registry.json}"
ACTION="summary"
JSON_MODE=0
usage() {
cat <<'EOF'
Usage: ops_erp_registry_tui.sh [--action summary|machines|modules|secrets] [--json]
Actions:
summary Show top-level ERP bridge summary
machines Show canonical machine table
modules Show module-to-layer ownership table
secrets Show secret scopes and consumers
Options:
--json Emit cockpit-v1 JSON
--help Show this help
EOF
}
emit_view() {
local view="$1"
python3 - "$REGISTRY_FILE" "$view" "$JSON_MODE" <<'PY'
import json
import sys
from pathlib import Path
registry_path = Path(sys.argv[1])
view = sys.argv[2]
json_mode = sys.argv[3] == "1"
data = json.loads(registry_path.read_text(encoding="utf-8"))
payload = {
"contract_version": "cockpit-v1",
"component": "ops-erp-registry",
"action": view,
"status": "ok",
"registry_file": str(registry_path),
"source_contract": data.get("contract_version"),
}
if view == "summary":
payload["ops_surface"] = data.get("ops_surface")
payload["layers"] = data.get("layers")
payload["machines_total"] = len(data.get("machines", []))
payload["modules_total"] = len(data.get("modules", []))
payload["secret_scopes_total"] = len(data.get("secret_scopes", []))
elif view == "machines":
payload["machines"] = data.get("machines", [])
elif view == "modules":
payload["modules"] = data.get("modules", [])
elif view == "secrets":
payload["secret_scopes"] = data.get("secret_scopes", [])
else:
raise SystemExit(f"unknown action: {view}")
if json_mode:
print(json.dumps(payload, indent=2))
raise SystemExit(0)
if view == "summary":
print("ERP / L'electronrare Ops Registry")
print(f"registry: {registry_path}")
print(f"ops surface: {data['ops_surface']['url']}")
print(f"layers: {len(data.get('layers', []))}")
print(f"machines: {len(data.get('machines', []))}")
print(f"modules: {len(data.get('modules', []))}")
print(f"secret scopes: {len(data.get('secret_scopes', []))}")
elif view == "machines":
print("Machines")
for machine in data.get("machines", []):
print(
f"- {machine['label']}: {machine['host']} | root={machine['canonical_root']} | "
f"role={machine['role']} | priority={machine['priority_order']} | "
f"load={machine['load_policy']} | owner={machine['owner_agent']}"
)
elif view == "modules":
print("Modules")
for module in data.get("modules", []):
print(
f"- {module['path']} | layer={module['layer']} | "
f"owner={module['owner_agent']} | purpose={module['purpose']}"
)
elif view == "secrets":
print("Secret scopes")
for secret in data.get("secret_scopes", []):
print(
f"- {secret['name']} | env={secret['env_var']} | "
f"owner={secret['owner_agent']} | consumer={secret['consumer_scope']}"
)
PY
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--json)
JSON_MODE=1
shift
;;
--help|-h)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
case "$ACTION" in
summary|machines|modules|secrets)
emit_view "$ACTION"
;;
*)
printf 'Unknown action: %s\n' "$ACTION" >&2
usage >&2
exit 2
;;
esac
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
REGISTRY_PATH="${ROOT_DIR}/specs/contracts/pcb_ai_fab_registry.json"
ACTION="summary"
OUTPUT_JSON=0
usage() {
cat <<USAGE
Usage: $(basename "$0") [--action summary|tools|gaps|lots|pipeline|readiness] [--json]
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--json)
OUTPUT_JSON=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
python3 - "$REGISTRY_PATH" "$ACTION" "$OUTPUT_JSON" <<'PY'
import json
import pathlib
import sys
registry_path = pathlib.Path(sys.argv[1])
action = sys.argv[2]
as_json = sys.argv[3] == "1"
data = json.loads(registry_path.read_text(encoding="utf-8"))
tools = data.get("tools", [])
if action == "summary":
payload = {
"status": "ok",
"scope": data.get("scope"),
"tool_count": len(tools),
"tool_ids": [tool["id"] for tool in tools],
"gaps": data.get("gaps", []),
"recommended_lots": data.get("recommended_lots", []),
}
elif action == "tools":
payload = {
"status": "ok",
"tools": [
{
"id": tool["id"],
"name": tool["name"],
"status": tool["status"],
"owner_agent": tool["owner_agent"],
"sub_agent": tool["sub_agent"],
"fit_with_kill_life": tool.get("fit_with_kill_life", []),
}
for tool in tools
],
}
elif action == "gaps":
payload = {
"status": "ok",
"gaps": data.get("gaps", []),
}
elif action == "lots":
payload = {
"status": "ok",
"recommended_lots": data.get("recommended_lots", []),
}
elif action in {"pipeline", "readiness"}:
payload = {
"status": "ok",
"pipeline": [
"T-HP-013: hypnoled bom analysis",
"T-RE-297: fab package contract",
"T-HP-035 / T-RE-298: kicad-happy parity",
"T-RE-296 / T-HP-033: quilter canary",
"T-HP-034: pcbdesigner evaluation",
"T-MS-002/003 + T-MA-016/017/021: mistral vm lots"
],
"current_priority": "fab-package-first",
"blocking_facts": [
"eda providers and agent from plan 26 are not implemented in active Mascarade repo",
"Hypnoled assets referenced by TODO 25 are not present in the current checkout"
]
}
else:
print(f"Unknown action: {action}", file=sys.stderr)
sys.exit(1)
if as_json:
print(json.dumps(payload, ensure_ascii=True, indent=2))
sys.exit(0)
if action == "summary":
print("PCB AI / FAB stack")
print(f"- scope: {payload['scope']}")
print(f"- tools: {payload['tool_count']}")
print("- ids: " + ", ".join(payload["tool_ids"]))
print("- gaps:")
for gap in payload["gaps"]:
print(f" - {gap}")
print("- lots:")
for lot in payload["recommended_lots"]:
print(f" - {lot['id']} [{lot['status']}] {lot['title']}")
elif action == "tools":
print("PCB AI tools")
for tool in payload["tools"]:
print(f"- {tool['id']}: {tool['name']} [{tool['status']}] owner={tool['owner_agent']}/{tool['sub_agent']}")
for fit in tool["fit_with_kill_life"]:
print(f" - {fit}")
elif action == "gaps":
print("PCB AI gaps")
for gap in payload["gaps"]:
print(f"- {gap}")
elif action == "lots":
print("PCB AI lots")
for lot in payload["recommended_lots"]:
print(f"- {lot['id']} [{lot['status']}] {lot['title']}")
elif action in {"pipeline", "readiness"}:
print("PCB AI pipeline")
print(f"- priority: {payload['current_priority']}")
print("- order:")
for item in payload["pipeline"]:
print(f" - {item}")
print("- blockers:")
for item in payload["blocking_facts"]:
print(f" - {item}")
PY
+329
View File
@@ -0,0 +1,329 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
CONTRACT_FILE="${ROOT_DIR}/specs/contracts/ops_mascarade_kill_life.contract.json"
OUTPUT_DIR="${ROOT_DIR}/artifacts/cockpit/product_contract_audit"
JSON_ONLY=0
MARKDOWN_ONLY=0
usage() {
cat <<'EOF'
Usage:
bash tools/cockpit/product_contract_audit.sh [--json|--markdown]
Description:
Audit statique du contrat produit ops / Mascarade / kill_life.
Le script ne relance pas les lanes runtime ; il vérifie les surfaces
cockpit et les points d'entrée source pour s'assurer que les ancrages
de continuité et les champs du contrat restent visibles.
Options:
--json Affiche seulement le JSON latest.
--markdown Affiche seulement le Markdown latest.
-h, --help Affiche cette aide.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--json)
JSON_ONLY=1
shift
;;
--markdown)
MARKDOWN_ONLY=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
if [[ "${JSON_ONLY}" == "1" && "${MARKDOWN_ONLY}" == "1" ]]; then
printf 'Use only one of --json or --markdown.\n' >&2
exit 2
fi
mkdir -p "${OUTPUT_DIR}"
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
JSON_FILE="${OUTPUT_DIR}/product_contract_audit_${TIMESTAMP}.json"
MARKDOWN_FILE="${OUTPUT_DIR}/product_contract_audit_${TIMESTAMP}.md"
LATEST_JSON="${OUTPUT_DIR}/latest.json"
LATEST_MARKDOWN="${OUTPUT_DIR}/latest.md"
read -r -d '' TARGETS_JSON <<'EOF_TARGETS' || true
[
{
"name": "render_product_contract_handoff",
"file": "tools/cockpit/render_product_contract_handoff.sh",
"kind": "handoff-runtime",
"required": ["--no-refresh", "degraded_reasons", "prereqs_refreshed", "kill_life_refresh_status", "daily_refresh_status"]
},
{
"name": "full_operator_lane",
"file": "tools/cockpit/full_operator_lane.sh",
"kind": "json-surface",
"required": ["owner", "decision", "resume_ref", "trust_level", "routing", "memory_entry", "product_contract_handoff_status", "product_contract_handoff_artifact", "product_contract_handoff_markdown"]
},
{
"name": "run_alignment_daily",
"file": "tools/cockpit/run_alignment_daily.sh",
"kind": "json-surface",
"required": ["owner", "decision", "resume_ref", "trust_level", "routing", "memory_entry", "product_contract_handoff_status", "product_contract_handoff_artifact", "product_contract_handoff_markdown"]
},
{
"name": "mascarade_runtime_health",
"file": "tools/cockpit/mascarade_runtime_health.sh",
"kind": "json-surface",
"required": ["owner", "decision", "resume_ref", "trust_level", "routing", "memory_entry"]
},
{
"name": "mascarade_incidents_tui",
"file": "tools/cockpit/mascarade_incidents_tui.sh",
"kind": "json-surface",
"required": ["resume_ref", "trust_level", "routing", "memory_entry"]
},
{
"name": "mascarade_incident_registry",
"file": "tools/cockpit/mascarade_incident_registry.sh",
"kind": "registry",
"required": ["resume_ref", "trust_level", "routing", "memory_entry"]
},
{
"name": "mascarade_logs_tui",
"file": "tools/cockpit/mascarade_logs_tui.sh",
"kind": "logs",
"required": ["resume_ref", "trust_level", "routing", "memory_entry"]
},
{
"name": "render_daily_operator_summary",
"file": "tools/cockpit/render_daily_operator_summary.sh",
"kind": "handoff",
"required": ["trust_level", "resume_ref", "routing", "memory_entry"]
},
{
"name": "render_weekly_refonte_summary",
"file": "tools/cockpit/render_weekly_refonte_summary.sh",
"kind": "handoff",
"required": ["trust_level", "resume_ref", "routing", "memory_entry"]
},
{
"name": "yiacad_operator_index",
"file": "tools/cockpit/yiacad_operator_index.sh",
"kind": "entrypoint",
"required": [
"artifacts/cockpit/kill_life_memory/latest.json",
"artifacts/cockpit/kill_life_memory/latest.md",
"artifacts/cockpit/daily_operator_summary_latest.md",
"artifacts/cockpit/product_contract_handoff/latest.json",
"artifacts/cockpit/product_contract_handoff/latest.md"
]
},
{
"name": "intelligence_tui",
"file": "tools/cockpit/intelligence_tui.sh",
"kind": "governance",
"required": ["kill_life_memory", "kill_life_json", "kill_life_markdown"]
},
{
"name": "refonte_tui",
"file": "tools/cockpit/refonte_tui.sh",
"kind": "entrypoint",
"required": [
"artifacts/cockpit/kill_life_memory/latest.json",
"artifacts/cockpit/kill_life_memory/latest.md",
"artifacts/cockpit/daily_operator_summary_latest.md",
"artifacts/cockpit/product_contract_handoff/latest.json",
"artifacts/cockpit/product_contract_handoff/latest.md"
]
},
{
"name": "lot_chain",
"file": "tools/cockpit/lot_chain.sh",
"kind": "pilot-chain",
"required": [
"artifacts/cockpit/kill_life_memory/latest.json",
"artifacts/cockpit/kill_life_memory/latest.md",
"T-LC-008"
]
}
]
EOF_TARGETS
PRODUCT_CONTRACT_TARGETS="${TARGETS_JSON}" python3 - "${CONTRACT_FILE}" "${JSON_FILE}" "${MARKDOWN_FILE}" <<'PY'
import datetime as dt
import json
import os
import pathlib
import sys
contract_path = pathlib.Path(sys.argv[1])
json_path = pathlib.Path(sys.argv[2])
markdown_path = pathlib.Path(sys.argv[3])
root_dir = contract_path.parents[2]
contract = json.loads(contract_path.read_text(encoding="utf-8"))
targets = json.loads(os.environ["PRODUCT_CONTRACT_TARGETS"])
required_fields = [
entry["name"]
for entry in contract.get("contract_fields", [])
if entry.get("required")
]
expected_required = [
"status",
"decision",
"owner",
"artifacts",
"next_step",
"resume_ref",
"trust_level",
"routing",
"memory_entry",
]
contract_gaps = [field for field in expected_required if field not in required_fields]
results = []
ok_count = 0
gap_count = 0
missing_count = 0
for target in targets:
file_path = root_dir / target["file"]
record = {
"name": target["name"],
"kind": target["kind"],
"file": target["file"],
"required": target["required"],
"missing": [],
}
if not file_path.exists():
record["status"] = "missing-file"
record["missing"] = target["required"]
missing_count += 1
results.append(record)
continue
content = file_path.read_text(encoding="utf-8")
missing = [needle for needle in target["required"] if needle not in content]
record["missing"] = missing
if missing:
record["status"] = "gap"
gap_count += 1
else:
record["status"] = "ok"
ok_count += 1
results.append(record)
status = "ok" if not contract_gaps and gap_count == 0 and missing_count == 0 else "degraded"
next_steps = []
if contract_gaps:
next_steps.append(
"Aligner specs/contracts/ops_mascarade_kill_life.contract.json sur les champs requis attendus."
)
if gap_count or missing_count:
next_steps.append(
"Corriger les surfaces en ecart pour garder la meme reprise entre ops, Mascarade et kill_life."
)
if not next_steps:
next_steps.append(
"Maintenir l audit statique a chaque lot de consolidation des surfaces cockpit."
)
payload = {
"contract_version": contract.get("version", "unknown"),
"component": "product-contract-audit",
"status": status,
"contract_status": status,
"generated_at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
"required_contract_fields": required_fields,
"expected_required_fields": expected_required,
"contract_field_gaps": contract_gaps,
"target_count": len(results),
"targets_ok": ok_count,
"targets_gap": gap_count,
"targets_missing_file": missing_count,
"targets": results,
"next_steps": next_steps,
"artifacts": [
"artifacts/cockpit/product_contract_audit/latest.json",
"artifacts/cockpit/product_contract_audit/latest.md",
"specs/contracts/ops_mascarade_kill_life.contract.json"
]
}
json_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
lines = [
"# Audit statique du contrat produit ops / Mascarade / kill_life",
"",
f"- Généré: `{payload['generated_at']}`",
f"- Version contrat: `{payload['contract_version']}`",
f"- Statut: `{payload['status']}`",
f"- Surfaces OK: `{ok_count}` / `{len(results)}`",
f"- Surfaces en écart: `{gap_count}`",
f"- Fichiers manquants: `{missing_count}`",
"",
"## Champs requis",
"",
f"- Attendus: `{', '.join(expected_required)}`",
f"- Contrat courant: `{', '.join(required_fields)}`",
]
if contract_gaps:
lines.extend([
"",
"## Écarts contrat",
"",
f"- Champs absents: `{', '.join(contract_gaps)}`",
])
lines.extend([
"",
"## Couverture par surface",
"",
])
for result in results:
lines.append(
f"- `{result['name']}` (`{result['kind']}`) via `{result['file']}`: `{result['status']}`"
)
if result["missing"]:
lines.append(f" - Manquants: `{', '.join(result['missing'])}`")
lines.extend([
"",
"## Prochaines actions",
"",
])
for step in next_steps:
lines.append(f"- {step}")
markdown_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
cp "${JSON_FILE}" "${LATEST_JSON}"
cp "${MARKDOWN_FILE}" "${LATEST_MARKDOWN}"
if [[ "${JSON_ONLY}" == "1" ]]; then
cat "${LATEST_JSON}"
elif [[ "${MARKDOWN_ONLY}" == "1" ]]; then
cat "${LATEST_MARKDOWN}"
else
printf 'product_contract_audit status=%s json=%s markdown=%s\n' \
"$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["status"])' "${LATEST_JSON}")" \
"${LATEST_JSON#${ROOT_DIR}/}" \
"${LATEST_MARKDOWN#${ROOT_DIR}/}"
fi
+640
View File
@@ -0,0 +1,640 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
LOG_DIR="${ROOT_DIR}/artifacts/refonte_tui"
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
LOG_FILE="${LOG_DIR}/refonte_tui_${TIMESTAMP}.log"
ACTION=""
DAYS_KEEP=14
YES=0
AUTO_PURGE=0
VERBOSE=0
MESH_LOAD_PROFILE="tower-first"
usage() {
cat <<'EOF_USAGE'
Usage:
bash tools/cockpit/refonte_tui.sh [options]
bash tools/cockpit/refonte_tui.sh --action <action> [options]
Options:
--action <readme|mesh-preflight|ssh-health|mascarade-health|mascarade-logs|mascarade-logs-summary|mascarade-logs-latest|mascarade-logs-list|mascarade-logs-purge|mascarade-incidents|mascarade-incidents-watch|mascarade-incidents-brief|mascarade-incidents-registry|mascarade-incidents-queue|mascarade-incidents-daily|mesh-health|lot-chain|mcp-check|yiacad-fusion|yiacad-fusion:prepare|yiacad-fusion:smoke|yiacad-fusion:status|yiacad-fusion:logs|yiacad-fusion:clean-logs|intelligence|intelligence:audit|intelligence:feature-map|intelligence:spec|intelligence:plan|intelligence:todo|intelligence:research|intelligence:owners|intelligence:logs-summary|intelligence:logs-list|intelligence:logs-latest|intelligence:purge-logs|weekly-summary|daily-align|validate|logs|clean-logs|log-ops|status|all>
Exécuter un lot directement au lieu d'un menu interactif.
--days <int> Nombre de jours pour la rétention de logs (clean-logs). Défaut: 14.
--mesh-load-profile <tower-first|photon-safe>
Profil P2P pour le préflight mesh (tower-first|photon-safe).
--yes Accepter les mises à jour écrivant les fichiers de plan.
--yes-auto Accepter automatiquement les purges via clean-logs.
--verbose Afficher la sortie live des commandes appelées.
-h, --help Affiche ce message.
Description:
Outil TUI textuel pour la refonte Kill_LIFE.
- loge toutes les exécutions dans artifacts/refonte_tui/
- lit les artefacts docs/plans/specs
- supprime les logs en mode contrôlé
- expose les opérations log_ops (résumé + purge) en mode contrôlé
EOF_USAGE
}
log_event() {
local level="$1"
shift
local msg="$*"
printf '[%s] %-7s %s\n' "$(date '+%Y-%m-%d %H:%M:%S %z')" "$level" "$msg" | tee -a "$LOG_FILE"
}
run_and_log() {
local label="$1"
shift
log_event "START" "${label}"
if [[ "${VERBOSE}" == "1" ]]; then
"$@" 2>&1 | tee -a "$LOG_FILE"
local rc="${PIPESTATUS[0]}"
else
"$@" >>"$LOG_FILE" 2>&1
local rc="$?"
fi
if [[ "$rc" -eq 0 ]]; then
log_event "OK" "${label}"
return 0
fi
log_event "FAIL" "${label} (code=$rc)"
return "$rc"
}
ensure_dirs() {
mkdir -p "$LOG_DIR"
[[ -f "$LOG_FILE" ]] || touch "$LOG_FILE"
}
cmd_readme_audit() {
local args=(bash tools/doc/readme_repo_coherence.sh all)
if [[ "$YES" -eq 1 ]]; then
args+=(--yes)
fi
run_and_log "Readme coherence + plan sync" "${args[@]}"
}
cmd_mesh_preflight() {
local args=(
bash tools/cockpit/mesh_sync_preflight.sh --json --load-profile "${MESH_LOAD_PROFILE}"
)
run_and_log "Tri-repo mesh sync preflight" "${args[@]}"
}
cmd_ssh_health() {
local args=(
bash tools/cockpit/ssh_healthcheck.sh --json
)
run_and_log "SSH operator health-check" "${args[@]}"
}
cmd_mascarade_health() {
local args=(
bash tools/cockpit/mascarade_runtime_health.sh --json
)
run_and_log "Mascarade/Ollama runtime health-check" "${args[@]}"
}
cmd_mascarade_logs() {
local logs_action="${1:-summary}"
local args=(
bash tools/cockpit/full_operator_lane.sh logs --json --logs-action "${logs_action}"
)
run_and_log "Mascarade/Ollama operator lane logs (${logs_action})" "${args[@]}"
}
cmd_mascarade_incidents() {
local incidents_action="${1:-summary}"
local args=(
bash tools/cockpit/mascarade_incidents_tui.sh --action "${incidents_action}" --lines 18
)
run_and_log "Mascarade incidents view (${incidents_action})" "${args[@]}"
}
cmd_lot_chain() {
local args=(bash tools/cockpit/lot_chain.sh all)
if [[ "$YES" -eq 1 ]]; then
args+=(--yes)
fi
run_and_log "Autonomous lot chain + plans" "${args[@]}"
}
cmd_yiacad_fusion() {
local yiacad_action="prepare"
if [[ "${ACTION}" == yiacad-fusion:* ]]; then
yiacad_action="${ACTION#yiacad-fusion:}"
fi
if [[ "${ACTION}" == "yiacad-fusion" ]]; then
yiacad_action="prepare"
fi
case "${yiacad_action}" in
prepare|smoke|status|logs|clean-logs)
;;
*)
echo "Action YiACAD inconnue: ${yiacad_action}" >&2
echo "Actions valides: prepare|smoke|status|logs|clean-logs" >&2
return 1
;;
esac
local args=(bash tools/cad/yiacad_fusion_lot.sh --action "${yiacad_action}")
if [[ "${yiacad_action}" == "clean-logs" ]]; then
args+=(--days "${DAYS_KEEP}")
fi
if [[ "$YES" -eq 1 ]]; then
args+=(--yes)
fi
run_and_log "YiACAD lot (${yiacad_action})" "${args[@]}"
}
cmd_weekly_summary() {
local args=(bash tools/cockpit/render_weekly_refonte_summary.sh)
run_and_log "Weekly refonte summary" "${args[@]}"
}
cmd_mcp_check() {
local args=(
bash tools/run_autonomous_next_lots.sh status
)
run_and_log "Autonomous lot status" "${args[@]}"
}
cmd_daily_align() {
local args=(bash tools/cockpit/run_alignment_daily.sh --json --mesh-load-profile "${MESH_LOAD_PROFILE}")
run_and_log "Daily alignment + repo refresh" "${args[@]}"
}
cmd_validate() {
local args=(
bash tools/test_python.sh --suite stable
)
run_and_log "Python stable suite" "${args[@]}"
}
cmd_log_ops() {
local args=(
bash tools/cockpit/log_ops.sh --action summary --json
)
run_and_log "Log ops summary" "${args[@]}"
}
cmd_mesh_health() {
local args=(
bash tools/cockpit/mesh_health_check.sh --json --load-profile "${MESH_LOAD_PROFILE}"
)
run_and_log "Mesh health reconcile" "${args[@]}"
}
cmd_intelligence_program() {
local intelligence_action="status"
if [[ "${ACTION}" == intelligence:* ]]; then
intelligence_action="${ACTION#intelligence:}"
fi
local args=(
bash tools/cockpit/intelligence_tui.sh --action "${intelligence_action}"
)
run_and_log "Intelligence program (${intelligence_action})" "${args[@]}"
}
list_logs() {
echo "=== Logs disponibles (refonte_tui) ==="
if ! ls -1 "$LOG_DIR"/*.log 2>/dev/null | head -n 20; then
echo "Aucun fichier log trouvé dans $LOG_DIR"
return 0
fi
}
collect_refonte_logs_by_mtime_desc() {
local path
local epoch
while IFS= read -r -d '' path; do
if epoch="$(stat -c '%Y' "$path" 2>/dev/null)"; then
: # GNU coreutils
elif epoch="$(stat -f '%m' "$path" 2>/dev/null)"; then
: # BSD/macOS
else
epoch="0"
fi
printf '%s %s\n' "$epoch" "$path"
done < <(find "$LOG_DIR" -maxdepth 1 -type f -name "*.log" -print0)
}
analyze_logs() {
echo "=== Analyse rapide des 20 logs les plus récents ==="
list_logs
echo
collect_refonte_logs_by_mtime_desc | sort -nr | head -n 20 | awk '{print $2}' | while IFS= read -r file; do
[[ -z "$file" ]] && continue
local_fail="$(grep -cE "FAIL|ERROR|KO|blocked|syntax fail|timeout" "$file" 2>/dev/null || true)"
local_lines="$(wc -l < "$file")"
local_name="$(basename "$file")"
printf '%s | lines=%s | fails=%s\n' "$local_name" "$local_lines" "$local_fail"
done
echo
echo "Dernier log (tail 30):"
local last_file
last_file="$(collect_refonte_logs_by_mtime_desc | sort -nr | head -n 1 | awk '{print $2}')"
if [[ -n "$last_file" ]]; then
tail -n 30 "$last_file"
else
echo "Aucun log trouvé."
fi
}
cmd_logs_report() {
list_logs
echo
analyze_logs
cmd_log_ops
}
clean_logs() {
local cutoff_days="${1:-$DAYS_KEEP}"
ensure_dirs
local target=$((cutoff_days))
if [[ "$target" -lt 1 ]]; then
echo "days doit être >= 1."
return 1
fi
local count=0
while IFS= read -r file; do
rm -f "$file"
((count += 1))
done < <(find "$LOG_DIR" -maxdepth 1 -type f -name "*.log" -mtime +"$target")
log_event "INFO" "Logs anciens supprimés (>${target}j): $count"
echo "Logs supprimés: $count"
cmd_log_ops
}
show_status() {
echo "=== Statuts et repères rapides ==="
echo "- Contrat mesh: docs/TRI_REPO_MESH_CONTRACT_2026-03-20.md"
echo "- Plan principal: specs/03_plan.md (section AUTO LOT-CHAIN PLAN)"
echo "- Tâches: specs/04_tasks.md"
echo "- Plan d'agents: docs/plans/12_plan_gestion_des_agents.md"
echo "- Lot chain: tools/autonomous_next_lots.py, tools/cockpit/lot_chain.sh"
echo "- Continuité kill_life JSON: artifacts/cockpit/kill_life_memory/latest.json"
echo "- Continuité kill_life Markdown: artifacts/cockpit/kill_life_memory/latest.md"
echo "- Handoff quotidien: artifacts/cockpit/daily_operator_summary_latest.md"
echo "- Handoff produit JSON: artifacts/cockpit/product_contract_handoff/latest.json"
echo "- Handoff produit Markdown: artifacts/cockpit/product_contract_handoff/latest.md"
echo "- Lot YiACAD: tools/cad/yiacad_fusion_lot.sh (prepare/smoke/status/logs)"
echo "- Préflight mesh: tools/cockpit/mesh_sync_preflight.sh"
echo "- Health consolidate: tools/cockpit/mesh_health_check.sh"
echo "- Santé SSH: tools/cockpit/ssh_healthcheck.sh"
echo "- Santé Mascarade: tools/cockpit/mascarade_runtime_health.sh"
echo "- Logs Mascarade: tools/cockpit/mascarade_logs_tui.sh"
echo "- Incidents Mascarade: tools/cockpit/mascarade_incidents_tui.sh"
echo "- MCP setup: docs/MCP_SETUP.md"
echo "- Manifeste: docs/REFACTOR_MANIFEST_2026-03-20.md"
echo "- Web research: docs/WEB_RESEARCH_OPEN_SOURCE_2026-03-20.md"
echo "- Intelligence spec: specs/agentic_intelligence_integration_spec.md"
echo "- Intelligence TUI: tools/cockpit/intelligence_tui.sh"
echo "- Log ops: tools/cockpit/log_ops.sh"
echo "- Cad fusion logs: artifacts/cad-fusion"
echo "- Weekly summary: artifacts/cockpit/weekly_refonte_summary.md"
}
run_all() {
cmd_mesh_preflight
cmd_ssh_health
cmd_mascarade_health
cmd_mascarade_logs
cmd_readme_audit
cmd_lot_chain
cmd_mcp_check
cmd_validate
cmd_log_ops
cmd_weekly_summary
}
menu() {
while true; do
echo
echo "== Refonte TUI Kill_LIFE =="
echo "1) Préflight mesh tri-repo"
echo "2) Santé SSH opérateur"
echo "3) Santé Mascarade/Ollama"
echo "4) Audit README + plans"
echo "5) Executer chain autonome (lot_chain)"
echo "6) Status lot détectés (autonomous)"
echo "7) Tests stables (python suite)"
echo "8) Vérification quotidienne (alignment)"
echo "9) Voir logs récents"
echo "10) Analyser logs (échecs + tail)"
echo "11) Statut repère"
echo "12) Résumé LogOps"
echo "13) Vérification santé consolidée (mesh/readme/logs)"
echo "14) Purger logs anciens (${DAYS_KEEP}j)"
echo "15) Lot YiACAD (prepare/smoke/status/logs/clean-logs)"
echo "16) Synthèse hebdomadaire refonte"
echo "17) Logs Mascarade/Ollama (summary)"
echo "18) Logs Mascarade/Ollama (latest)"
echo "19) Logs Mascarade/Ollama (list)"
echo "20) Logs Mascarade/Ollama (purge)"
echo "21) Incidents Mascarade (summary)"
echo "22) Incidents Mascarade (watch)"
echo "23) Incidents Mascarade (brief)"
echo "24) Incidents Mascarade (registry)"
echo "25) Incidents Mascarade (queue)"
echo "26) Incidents Mascarade (daily)"
echo "27) Intelligence program (status)"
echo "28) Intelligence program (plan)"
echo "29) Intelligence program (todo)"
echo "30) Intelligence program (research)"
echo "0) Quitter"
echo -n "Choix: "
read -r choice
case "${choice}" in
1)
cmd_mesh_preflight
;;
2)
cmd_ssh_health
;;
3)
cmd_mascarade_health
;;
4)
cmd_readme_audit
;;
5)
cmd_lot_chain
;;
6)
cmd_mcp_check
;;
7)
cmd_validate
;;
8)
cmd_daily_align
;;
9)
list_logs
;;
10)
cmd_logs_report
;;
11)
show_status
;;
12)
cmd_log_ops
;;
13)
cmd_mesh_health
;;
14)
if [[ "$AUTO_PURGE" -eq 1 ]]; then
clean_logs "$DAYS_KEEP"
else
echo "Confirmer la suppression des logs > ${DAYS_KEEP} jours ? [y/N]"
read -r confirm
if [[ "${confirm}" == "y" || "${confirm}" == "Y" ]]; then
clean_logs "$DAYS_KEEP"
else
echo "Annulé."
fi
fi
;;
15)
cmd_yiacad_fusion
;;
16)
cmd_weekly_summary
;;
17)
cmd_mascarade_logs summary
;;
18)
cmd_mascarade_logs latest
;;
19)
cmd_mascarade_logs list
;;
20)
cmd_mascarade_logs purge
;;
21)
cmd_mascarade_incidents summary
;;
22)
cmd_mascarade_incidents watch
;;
23)
cmd_mascarade_incidents brief
;;
24)
cmd_mascarade_incidents registry
;;
25)
cmd_mascarade_incidents queue
;;
26)
cmd_mascarade_incidents daily
;;
27)
ACTION="intelligence"
cmd_intelligence_program
;;
28)
ACTION="intelligence:plan"
cmd_intelligence_program
;;
29)
ACTION="intelligence:todo"
cmd_intelligence_program
;;
30)
ACTION="intelligence:research"
cmd_intelligence_program
;;
0)
echo "Sortie."
break
;;
*)
echo "Choix invalide: ${choice}"
;;
esac
done
}
main() {
ensure_dirs
log_event "INFO" "refonte_tui start action=${ACTION:-interactive} yes=${YES} verbose=${VERBOSE}"
case "${ACTION}" in
readme)
cmd_readme_audit
;;
"mesh-preflight")
cmd_mesh_preflight
;;
"ssh-health")
cmd_ssh_health
;;
"mascarade-health")
cmd_mascarade_health
;;
"mascarade-logs")
cmd_mascarade_logs summary
;;
"mascarade-logs-summary")
cmd_mascarade_logs summary
;;
"mascarade-logs-latest")
cmd_mascarade_logs latest
;;
"mascarade-logs-list")
cmd_mascarade_logs list
;;
"mascarade-logs-purge")
cmd_mascarade_logs purge
;;
"mascarade-incidents")
cmd_mascarade_incidents summary
;;
"mascarade-incidents-watch")
cmd_mascarade_incidents watch
;;
"mascarade-incidents-brief")
cmd_mascarade_incidents brief
;;
"mascarade-incidents-registry")
cmd_mascarade_incidents registry
;;
"mascarade-incidents-queue")
cmd_mascarade_incidents queue
;;
"mascarade-incidents-daily")
cmd_mascarade_incidents daily
;;
"lot-chain")
cmd_lot_chain
;;
"mcp-check")
cmd_mcp_check
;;
daily-align)
cmd_daily_align
;;
validate)
cmd_validate
;;
logs)
cmd_logs_report
;;
"clean-logs")
if [[ "$AUTO_PURGE" -ne 1 ]]; then
echo "Refus de purger sans --yes-auto pour l'action non interactive clean-logs." >&2
echo "Relancer avec: bash tools/cockpit/refonte_tui.sh --action clean-logs --days ${DAYS_KEEP} --yes-auto" >&2
exit 2
fi
clean_logs "$DAYS_KEEP"
;;
"log-ops")
cmd_log_ops
;;
yiacad-fusion|yiacad-fusion:*)
cmd_yiacad_fusion
;;
intelligence|intelligence:*)
cmd_intelligence_program
;;
"weekly-summary")
cmd_weekly_summary
;;
"mesh-health")
cmd_mesh_health
;;
status)
show_status
;;
all)
run_all
;;
"")
menu
;;
*)
echo "Action inconnue: ${ACTION}"
usage
exit 1
;;
esac
log_event "INFO" "refonte_tui end"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
if [[ $# -lt 2 ]]; then
echo "--action nécessite une valeur." >&2
exit 1
fi
ACTION="$2"
shift 2
;;
--days)
if [[ $# -lt 2 ]]; then
echo "--days nécessite une valeur." >&2
exit 1
fi
DAYS_KEEP="$2"
shift 2
;;
--mesh-load-profile)
if [[ $# -lt 2 ]]; then
echo "--mesh-load-profile nécessite une valeur." >&2
exit 1
fi
MESH_LOAD_PROFILE="$2"
if [[ ! "${MESH_LOAD_PROFILE}" =~ ^(tower-first|photon-safe)$ ]]; then
echo "Profil invalide: ${MESH_LOAD_PROFILE}. Valeurs: tower-first|photon-safe" >&2
exit 2
fi
shift 2
;;
--yes)
YES=1
shift
;;
--yes-auto)
YES=1
AUTO_PURGE=1
shift
;;
--verbose)
VERBOSE=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Option inconnue: $1" >&2
usage
exit 1
;;
esac
done
main "$@"
@@ -0,0 +1,230 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
COCKPIT_DIR="$ROOT_DIR/artifacts/cockpit"
OUTPUT_MODE="text"
DAILY_LOG=""
BRIEF_MARKDOWN=""
REGISTRY_MARKDOWN=""
QUEUE_MARKDOWN=""
usage() {
cat <<'EOF'
Usage:
bash tools/cockpit/render_daily_operator_summary.sh [--daily-log FILE] [--brief-markdown FILE] [--registry-markdown FILE] [--queue-markdown FILE] [--json]
Options:
--daily-log FILE Daily alignment log to summarize
--brief-markdown FILE Mascarade brief markdown source
--registry-markdown FILE Mascarade registry markdown source
--queue-markdown FILE Mascarade queue markdown source
--json Emit cockpit-v1 JSON to stdout
-h,--help Show this help
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--daily-log)
DAILY_LOG="${2:-}"
shift 2
;;
--brief-markdown)
BRIEF_MARKDOWN="${2:-}"
shift 2
;;
--registry-markdown)
REGISTRY_MARKDOWN="${2:-}"
shift 2
;;
--queue-markdown)
QUEUE_MARKDOWN="${2:-}"
shift 2
;;
--json)
OUTPUT_MODE="json"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
mkdir -p "$COCKPIT_DIR"
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
OUT_MD="$COCKPIT_DIR/daily_operator_summary_${RUN_ID}.md"
OUT_JSON="$COCKPIT_DIR/daily_operator_summary_${RUN_ID}.json"
python3 - "$COCKPIT_DIR" "$DAILY_LOG" "$BRIEF_MARKDOWN" "$REGISTRY_MARKDOWN" "$QUEUE_MARKDOWN" "$OUT_MD" "$OUT_JSON" <<'PY'
import json
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
cockpit_dir = Path(sys.argv[1])
daily_log_arg = sys.argv[2]
brief_arg = sys.argv[3]
registry_arg = sys.argv[4]
queue_arg = sys.argv[5]
out_md = Path(sys.argv[6])
out_json = Path(sys.argv[7])
def latest_matching(pattern: str):
files = sorted(cockpit_dir.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True)
return files[0] if files else None
daily_log = Path(daily_log_arg) if daily_log_arg else latest_matching("machine_alignment_daily_*.log")
brief_md = Path(brief_arg) if brief_arg else cockpit_dir / "mascarade_incident_brief_latest.md"
registry_md = Path(registry_arg) if registry_arg else cockpit_dir / "mascarade_incident_registry_latest.md"
queue_md = Path(queue_arg) if queue_arg else cockpit_dir / "mascarade_incident_queue_latest.md"
registry_json = cockpit_dir / "mascarade_incident_registry_latest.json"
memory_json = cockpit_dir / "kill_life_memory" / "latest.json"
memory_md = cockpit_dir / "kill_life_memory" / "latest.md"
def read_lines(path: Path):
try:
return path.read_text(encoding="utf-8", errors="replace").splitlines()
except Exception:
return []
def load_json(path: Path):
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
summary_lines = [line for line in read_lines(daily_log) if line.startswith("[summary]")] if daily_log and daily_log.exists() else []
brief_tail = read_lines(brief_md)[-16:] if brief_md.exists() else ["Brief indisponible."]
registry_tail = read_lines(registry_md)[-16:] if registry_md.exists() else ["Registre indisponible."]
queue_tail = read_lines(queue_md)[-16:] if queue_md.exists() else ["Queue indisponible."]
registry_payload = load_json(registry_json) if registry_json.exists() else {}
memory_payload = load_json(memory_json) if memory_json.exists() else {}
priority_counts = registry_payload.get("priority_counts", {}) if isinstance(registry_payload.get("priority_counts"), dict) else {}
severity_counts = registry_payload.get("severity_counts", {}) if isinstance(registry_payload.get("severity_counts"), dict) else {}
memory_entry = memory_payload.get("entry", {}) if isinstance(memory_payload.get("entry"), dict) else {}
routing = memory_entry.get("routing", {}) if isinstance(memory_entry.get("routing"), dict) else {}
resume_ref = memory_payload.get("resume_ref") or memory_entry.get("resume_ref", "")
trust_level = memory_payload.get("trust_level") or memory_entry.get("trust_level", "inferred")
memory_tail = read_lines(memory_md)[-12:] if memory_md.exists() else ["Kill life memory indisponible."]
overall = "ok"
reasons = []
for line in summary_lines:
if "result=degraded" in line or "status=degraded" in line:
overall = "degraded"
if "result=ko" in line or "status=failed" in line or "status=ko" in line:
overall = "degraded"
reasons.append(line)
for label, path in (("brief", brief_md), ("registry", registry_md), ("queue", queue_md)):
if not path.exists():
overall = "degraded"
reasons.append(f"missing-{label}-markdown")
if not memory_json.exists():
overall = "degraded"
reasons.append("missing-kill-life-memory")
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
lines = [
"# Daily operator summary",
"",
f"- generated_at: {generated_at}",
f"- daily_log: {daily_log if daily_log else 'none'}",
f"- mascarade_brief: {brief_md if brief_md.exists() else 'missing'}",
f"- mascarade_registry: {registry_md if registry_md.exists() else 'missing'}",
f"- mascarade_queue: {queue_md if queue_md.exists() else 'missing'}",
"",
"## Incident priority rollup",
"",
f"- priority P1/P2/P3: {priority_counts.get('P1', 0)}/{priority_counts.get('P2', 0)}/{priority_counts.get('P3', 0)}",
f"- severity high/medium/low: {severity_counts.get('high', 0)}/{severity_counts.get('medium', 0)}/{severity_counts.get('low', 0)}",
"",
"## Execution continuity",
"",
f"- trust_level: {trust_level}",
f"- resume_ref: {resume_ref or 'missing'}",
f"- owner: {memory_entry.get('owner', 'unknown')}",
f"- selected_target: {routing.get('selected_target', 'unknown')}",
f"- selected_host: {routing.get('selected_host', 'unknown')}",
f"- kill_life_memory: {memory_md if memory_md.exists() else 'missing'}",
"",
"## Daily alignment highlights",
"",
]
if summary_lines:
lines.extend([f"- {line}" for line in summary_lines])
else:
lines.append("- no summary line found")
lines.extend(["", "## Mascarade incident brief", "", "```text"])
lines.extend(brief_tail)
lines.extend(["```", "", "## Mascarade incident registry", "", "```text"])
lines.extend(registry_tail)
lines.extend(["```", "", "## Mascarade incident queue", "", "```text"])
lines.extend(queue_tail)
lines.extend(["```", "", "## Kill life execution memory", "", "```text"])
lines.extend(memory_tail)
lines.extend(["```"])
out_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
shutil.copyfile(out_md, out_md.with_name("daily_operator_summary_latest.md"))
payload = {
"contract_version": "cockpit-v1",
"component": "daily-operator-summary",
"action": "render",
"status": overall,
"contract_status": overall,
"generated_at": generated_at,
"daily_log": str(daily_log) if daily_log else "",
"brief_markdown": str(brief_md) if brief_md.exists() else "",
"registry_markdown": str(registry_md) if registry_md.exists() else "",
"queue_markdown": str(queue_md) if queue_md.exists() else "",
"owner": memory_entry.get("owner", "SyncOps"),
"resume_ref": resume_ref,
"trust_level": trust_level,
"routing": routing,
"memory_entry": memory_entry,
"memory_markdown": str(memory_md) if memory_md.exists() else "",
"priority_counts": priority_counts,
"severity_counts": severity_counts,
"markdown_file": str(out_md),
"latest_markdown_file": str(out_md.with_name("daily_operator_summary_latest.md")),
"artifacts": [
str(out_md),
str(out_md.with_name("daily_operator_summary_latest.md")),
] + ([str(daily_log)] if daily_log else []) + ([str(memory_json)] if memory_json.exists() else []),
"degraded_reasons": reasons[:5],
"next_steps": ["Review the latest Mascarade brief, registry and queue before operator handoff."],
}
out_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
shutil.copyfile(out_json, out_json.with_name("daily_operator_summary_latest.json"))
PY
if [[ "$OUTPUT_MODE" == "json" ]]; then
cat "$OUT_JSON"
else
cat <<EOF
Daily operator summary
markdown: $OUT_MD
json: $OUT_JSON
EOF
fi
@@ -0,0 +1,246 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
COCKPIT_DIR="$ROOT_DIR/artifacts/cockpit"
RUNTIME_DIR="$ROOT_DIR/artifacts/ops/mascarade_runtime_health"
OPERATOR_DIR="$ROOT_DIR/artifacts/operator_lane"
OUTPUT_MODE="text"
usage() {
cat <<'EOF'
Usage:
bash tools/cockpit/render_mascarade_incident_brief.sh [--json]
Options:
--json Emit cockpit-v1 JSON to stdout
-h,--help Show this help
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--json)
OUTPUT_MODE="json"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
mkdir -p "$COCKPIT_DIR" "$RUNTIME_DIR" "$OPERATOR_DIR"
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
OUT_MD="$COCKPIT_DIR/mascarade_incident_brief_${RUN_ID}.md"
OUT_JSON="$COCKPIT_DIR/mascarade_incident_brief_${RUN_ID}.json"
python3 - "$RUNTIME_DIR" "$OPERATOR_DIR" "$OUT_MD" "$OUT_JSON" <<'PY'
import json
import os
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
runtime_dir = Path(sys.argv[1])
operator_dir = Path(sys.argv[2])
out_md = Path(sys.argv[3])
out_json = Path(sys.argv[4])
def read_json(path: Path):
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
def latest_matching(directory: Path, prefix: str, suffix: str = ".json", exclude_contains=None):
exclude_contains = exclude_contains or []
candidates = []
if directory.exists():
for path in directory.iterdir():
if not path.is_file():
continue
if not path.name.startswith(prefix) or not path.name.endswith(suffix):
continue
if any(token in path.name for token in exclude_contains):
continue
candidates.append(path)
candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return candidates[0] if candidates else None
runtime_latest = runtime_dir / "latest.json"
runtime_data = read_json(runtime_latest)
operator_summary_path = latest_matching(operator_dir, "full_operator_lane_", ".json", exclude_contains=["mascarade_health", "mascarade_logs"])
operator_summary = read_json(operator_summary_path) if operator_summary_path else {}
operator_logs_path = latest_matching(operator_dir, "full_operator_lane_mascarade_logs_", ".json")
operator_logs = read_json(operator_logs_path) if operator_logs_path else {}
memory_json = cockpit_dir / "kill_life_memory" / "latest.json"
memory_md = cockpit_dir / "kill_life_memory" / "latest.md"
memory_payload = read_json(memory_json) if memory_json.exists() else {}
memory_entry = memory_payload.get("entry", {}) if isinstance(memory_payload.get("entry"), dict) else {}
routing = memory_entry.get("routing", {}) if isinstance(memory_entry.get("routing"), dict) else {}
resume_ref = memory_payload.get("resume_ref") or memory_entry.get("resume_ref", "")
trust_level = memory_payload.get("trust_level") or memory_entry.get("trust_level", "inferred")
runtime_status = runtime_data.get("status", "missing")
runtime_provider = runtime_data.get("provider", "unknown")
runtime_model = runtime_data.get("model", "unknown")
runtime_checked_at = runtime_data.get("checked_at", "")
runtime_next_steps = runtime_data.get("next_steps", []) if isinstance(runtime_data.get("next_steps"), list) else []
runtime_reasons = runtime_data.get("degraded_reasons", []) if isinstance(runtime_data.get("degraded_reasons"), list) else []
lane_status = operator_summary.get("status", "missing")
lane_error = operator_summary.get("error", "")
lane_hint = operator_summary.get("hint", "")
lane_command = operator_summary.get("suggested_command", "")
lane_url = operator_summary.get("url", "")
logs_status = operator_logs.get("status", "missing")
logs_action = operator_logs.get("logs_action", operator_logs.get("action", "summary"))
logs_details = operator_logs.get("details", {}) if isinstance(operator_logs.get("details"), dict) else operator_logs
latest_runtime = logs_details.get("latest_runtime", {}) if isinstance(logs_details.get("latest_runtime"), dict) else {}
latest_tail = logs_details.get("latest_log_tail", []) if isinstance(logs_details.get("latest_log_tail"), list) else []
stale_count = logs_details.get("stale_candidate_count", logs_details.get("candidate_count", 0))
overall = "ok"
reasons = []
if runtime_status not in {"ok", "ready", "success", "done"}:
overall = "degraded"
reasons.append(f"runtime-{runtime_status}")
if lane_status not in {"ok", "ready", "success", "done", ""}:
overall = "degraded"
reasons.append(f"lane-{lane_status}")
if logs_status not in {"ok", "ready", "success", "done"}:
overall = "degraded"
reasons.append(f"logs-{logs_status}")
next_steps = []
for item in runtime_next_steps:
if item and item not in next_steps:
next_steps.append(item)
for item in [lane_hint, lane_command]:
if item and item not in next_steps:
next_steps.append(item)
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
lines = [
"# Mascarade incident brief",
"",
f"- generated_at: {generated_at}",
f"- overall_status: {overall}",
"",
"## Runtime",
"",
f"- status: {runtime_status}",
f"- provider/model: {runtime_provider} / {runtime_model}",
f"- checked_at: {runtime_checked_at or 'unknown'}",
]
if runtime_reasons:
lines.extend(["", "### Runtime degraded reasons", ""])
lines.extend([f"- {item}" for item in runtime_reasons])
lines.extend([
"",
"## Execution continuity",
"",
f"- trust_level: {trust_level}",
f"- resume_ref: {resume_ref or 'missing'}",
f"- owner: {memory_entry.get('owner', 'unknown')}",
f"- selected_target: {routing.get('selected_target', 'unknown')}",
f"- selected_host: {routing.get('selected_host', 'unknown')}",
f"- kill_life_memory: {memory_md if memory_md.exists() else 'missing'}",
"",
"## Operator lane",
"",
f"- status: {lane_status or 'unknown'}",
f"- error: {lane_error or 'none'}",
f"- url: {lane_url or 'n/a'}",
])
if lane_hint or lane_command:
lines.extend(["", "### Operator hints", ""])
if lane_hint:
lines.append(f"- {lane_hint}")
if lane_command:
lines.append(f"- suggested_command: `{lane_command}`")
lines.extend([
"",
"## Logs",
"",
f"- status: {logs_status}",
f"- action: {logs_action}",
f"- stale_candidates: {stale_count}",
])
runtime_path = latest_runtime.get("path", "")
if runtime_path:
lines.append(f"- latest_runtime_path: `{runtime_path}`")
if latest_tail:
lines.extend(["", "### Latest log tail", ""])
lines.extend([f"- `{item}`" for item in latest_tail[:8]])
if next_steps:
lines.extend(["", "## Next steps", ""])
lines.extend([f"- {item}" for item in next_steps])
out_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
shutil.copyfile(out_md, out_md.with_name("mascarade_incident_brief_latest.md"))
payload = {
"contract_version": "cockpit-v1",
"component": "mascarade-incident-brief",
"action": "render",
"status": overall,
"contract_status": overall,
"generated_at": generated_at,
"owner": memory_entry.get("owner", "Runtime-Companion"),
"resume_ref": resume_ref,
"trust_level": trust_level,
"routing": routing,
"memory_entry": memory_entry,
"memory_markdown": str(memory_md) if memory_md.exists() else "",
"markdown_file": str(out_md),
"latest_markdown_file": str(out_md.with_name("mascarade_incident_brief_latest.md")),
"runtime_file": str(runtime_latest),
"operator_summary_file": str(operator_summary_path) if operator_summary_path else "",
"operator_logs_file": str(operator_logs_path) if operator_logs_path else "",
"artifacts": [
str(out_md),
str(out_md.with_name("mascarade_incident_brief_latest.md")),
str(runtime_latest),
] + ([str(operator_summary_path)] if operator_summary_path else []) + ([str(operator_logs_path)] if operator_logs_path else []) + ([str(memory_json)] if memory_json.exists() else []),
"degraded_reasons": reasons,
"next_steps": next_steps,
}
out_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
shutil.copyfile(out_json, out_json.with_name("mascarade_incident_brief_latest.json"))
print(json.dumps(payload, ensure_ascii=False, indent=2))
PY
if [[ "$OUTPUT_MODE" == "json" ]]; then
cat "$OUT_JSON"
else
cat <<EOF
Mascarade incident brief
markdown: $OUT_MD
json: $OUT_JSON
EOF
fi
@@ -0,0 +1,228 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
COCKPIT_DIR="$ROOT_DIR/artifacts/cockpit"
OUTPUT_MODE="text"
usage() {
cat <<'EOF'
Usage:
bash tools/cockpit/render_mascarade_incident_queue.sh [--json]
Options:
--json Emit cockpit-v1 JSON to stdout
-h,--help Show this help
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--json)
OUTPUT_MODE="json"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
mkdir -p "$COCKPIT_DIR"
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
OUT_MD="$COCKPIT_DIR/mascarade_incident_queue_${RUN_ID}.md"
OUT_JSON="$COCKPIT_DIR/mascarade_incident_queue_${RUN_ID}.json"
python3 - "$COCKPIT_DIR" "$OUT_MD" "$OUT_JSON" <<'PY'
import json
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
cockpit_dir = Path(sys.argv[1])
out_md = Path(sys.argv[2])
out_json = Path(sys.argv[3])
registry_file = cockpit_dir / "mascarade_incident_registry_latest.json"
memory_json = cockpit_dir / "kill_life_memory" / "latest.json"
memory_md = cockpit_dir / "kill_life_memory" / "latest.md"
def load_json(path: Path):
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
def parse_timestamp(value: str) -> float:
if not value:
return 0.0
candidates = (
value,
value.replace(" UTC", "+00:00"),
value.replace("Z", "+00:00"),
)
for candidate in candidates:
try:
return datetime.fromisoformat(candidate).timestamp()
except ValueError:
continue
for pattern in ("%Y-%m-%d %H:%M:%S UTC", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d %H:%M:%S%z"):
try:
return datetime.strptime(value, pattern).timestamp()
except ValueError:
continue
return 0.0
registry = load_json(registry_file)
memory_payload = load_json(memory_json) if memory_json.exists() else {}
memory_entry = memory_payload.get("entry", {}) if isinstance(memory_payload.get("entry"), dict) else {}
routing = memory_entry.get("routing", {}) if isinstance(memory_entry.get("routing"), dict) else {}
resume_ref = memory_payload.get("resume_ref") or memory_entry.get("resume_ref", "")
trust_level = memory_payload.get("trust_level") or memory_entry.get("trust_level", "inferred")
entries = registry.get("entries", [])
if not isinstance(entries, list):
entries = []
priority_rank = {"P1": 0, "P2": 1, "P3": 2}
severity_rank = {"high": 0, "medium": 1, "low": 2}
queue_entries = sorted(
entries,
key=lambda item: (
priority_rank.get(item.get("priority"), 9),
severity_rank.get(item.get("severity"), 9),
-parse_timestamp(item.get("ts", "")),
),
)
priority_counts = {"P1": 0, "P2": 0, "P3": 0}
severity_counts = {"high": 0, "medium": 0, "low": 0}
for entry in queue_entries:
priority = entry.get("priority", "P3")
severity = entry.get("severity", "low")
priority_counts[priority] = priority_counts.get(priority, 0) + 1
severity_counts[severity] = severity_counts.get(severity, 0) + 1
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
status = "ok"
degraded_reasons = []
if not registry_file.exists():
status = "degraded"
degraded_reasons.append("registry-missing")
elif queue_entries:
status = "degraded"
degraded_reasons.extend(
[
f"{entry.get('priority', 'P3')}:{entry.get('source', 'unknown')}:{entry.get('status', 'unknown')}"
for entry in queue_entries[:5]
]
)
next_steps = []
for entry in queue_entries:
for step in entry.get("next_steps", []):
if step and step not in next_steps:
next_steps.append(step)
if not next_steps:
next_steps.append("Review the latest Mascarade incident registry before operator handoff.")
lines = [
"# Mascarade incident queue",
"",
f"- generated_at: {generated_at}",
f"- registry_file: {registry_file if registry_file.exists() else 'missing'}",
f"- trust_level: {trust_level}",
f"- resume_ref: {resume_ref or 'missing'}",
f"- selected_target: {routing.get('selected_target', 'unknown')}",
f"- kill_life_memory: {memory_md if memory_md.exists() else 'missing'}",
f"- entry_count: {len(queue_entries)}",
"- queue_order: priority -> severity -> recency",
"",
"## Queue summary",
"",
"| Priority | Count | Severity | Count |",
"| --- | --- | --- | --- |",
f"| P1 | {priority_counts['P1']} | high | {severity_counts['high']} |",
f"| P2 | {priority_counts['P2']} | medium | {severity_counts['medium']} |",
f"| P3 | {priority_counts['P3']} | low | {severity_counts['low']} |",
"",
"## Incident queue",
"",
"| Rank | Priority | Severity | Status | Source | Timestamp | Path | Reasons |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
]
if queue_entries:
for index, entry in enumerate(queue_entries, start=1):
reasons = ", ".join([reason for reason in entry.get("reasons", []) if reason]) or "none"
lines.append(
f"| {index} | {entry.get('priority', 'P3')} | {entry.get('severity', 'low')} | "
f"{entry.get('status', 'unknown')} | {entry.get('source', 'unknown')} | "
f"{entry.get('ts') or 'n/a'} | `{entry.get('path') or 'n/a'}` | {reasons} |"
)
else:
lines.append("| 0 | P3 | low | ok | queue | n/a | `n/a` | none |")
lines.extend(["", "## Next steps", ""])
for step in next_steps[:10]:
lines.append(f"- {step}")
out_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
shutil.copyfile(out_md, out_md.with_name("mascarade_incident_queue_latest.md"))
payload = {
"contract_version": "cockpit-v1",
"component": "mascarade-incident-queue",
"action": "render",
"status": status,
"contract_status": status,
"generated_at": generated_at,
"owner": memory_entry.get("owner", "SyncOps"),
"resume_ref": resume_ref,
"trust_level": trust_level,
"routing": routing,
"memory_entry": memory_entry,
"memory_markdown": str(memory_md) if memory_md.exists() else "",
"registry_file": str(registry_file) if registry_file.exists() else "",
"entry_count": len(queue_entries),
"queue_order": "priority,severity,recency",
"priority_counts": priority_counts,
"severity_counts": severity_counts,
"entries": queue_entries,
"markdown_file": str(out_md),
"latest_markdown_file": str(out_md.with_name("mascarade_incident_queue_latest.md")),
"artifacts": [
str(out_md),
str(out_md.with_name("mascarade_incident_queue_latest.md")),
str(out_json),
str(out_json.with_name("mascarade_incident_queue_latest.json")),
] + ([str(registry_file)] if registry_file.exists() else []) + ([str(memory_json)] if memory_json.exists() else []),
"degraded_reasons": degraded_reasons,
"next_steps": next_steps[:10],
}
out_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
shutil.copyfile(out_json, out_json.with_name("mascarade_incident_queue_latest.json"))
print(json.dumps(payload, ensure_ascii=False, indent=2))
PY
if [[ "$OUTPUT_MODE" == "json" ]]; then
cat "$OUT_JSON"
else
cat <<EOF
Mascarade incident queue
markdown: $OUT_MD
json: $OUT_JSON
EOF
fi
@@ -0,0 +1,181 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
COCKPIT_DIR="$ROOT_DIR/artifacts/cockpit"
OUTPUT_MODE="text"
usage() {
cat <<'EOF'
Usage:
bash tools/cockpit/render_mascarade_incident_watch.sh [--json]
Options:
--json Emit cockpit-v1 JSON to stdout
-h,--help Show this help
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--json)
OUTPUT_MODE="json"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
mkdir -p "$COCKPIT_DIR"
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
OUT_MD="$COCKPIT_DIR/mascarade_incident_watch_${RUN_ID}.md"
OUT_JSON="$COCKPIT_DIR/mascarade_incident_watch_${RUN_ID}.json"
python3 - "$COCKPIT_DIR" "$OUT_MD" "$OUT_JSON" <<'PY'
import json
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
cockpit_dir = Path(sys.argv[1])
out_md = Path(sys.argv[2])
out_json = Path(sys.argv[3])
registry_file = cockpit_dir / "mascarade_incident_registry_latest.json"
queue_file = cockpit_dir / "mascarade_incident_queue_latest.json"
daily_file = cockpit_dir / "daily_operator_summary_latest.json"
memory_json = cockpit_dir / "kill_life_memory" / "latest.json"
memory_md = cockpit_dir / "kill_life_memory" / "latest.md"
def load_json(path: Path):
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
registry = load_json(registry_file)
queue = load_json(queue_file)
daily = load_json(daily_file)
memory_payload = load_json(memory_json) if memory_json.exists() else {}
memory_entry = memory_payload.get("entry", {}) if isinstance(memory_payload.get("entry"), dict) else {}
routing = memory_entry.get("routing", {}) if isinstance(memory_entry.get("routing"), dict) else {}
resume_ref = memory_payload.get("resume_ref") or memory_entry.get("resume_ref", "")
trust_level = memory_payload.get("trust_level") or memory_entry.get("trust_level", "inferred")
priority_counts = registry.get("priority_counts", {}) if isinstance(registry.get("priority_counts"), dict) else {}
severity_counts = registry.get("severity_counts", {}) if isinstance(registry.get("severity_counts"), dict) else {}
entries = queue.get("entries", []) if isinstance(queue.get("entries"), list) else []
top_entries = entries[:5]
next_steps = []
for candidate in (queue.get("next_steps", []), registry.get("next_steps", []), daily.get("next_steps", [])):
if isinstance(candidate, list):
for step in candidate:
if step and step not in next_steps:
next_steps.append(step)
if not next_steps:
next_steps.append("Refresh the latest queue and registry artifacts before the next operator handoff.")
status = "ok" if registry_file.exists() or queue_file.exists() else "degraded"
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
lines = [
"# Mascarade incident watch",
"",
f"- generated_at: {generated_at}",
f"- registry_file: {registry_file if registry_file.exists() else 'missing'}",
f"- queue_file: {queue_file if queue_file.exists() else 'missing'}",
f"- daily_file: {daily_file if daily_file.exists() else 'missing'}",
f"- trust_level: {trust_level}",
f"- resume_ref: {resume_ref or 'missing'}",
f"- selected_target: {routing.get('selected_target', 'unknown')}",
f"- kill_life_memory: {memory_md if memory_md.exists() else 'missing'}",
"",
"## Rollup",
"",
f"- priority P1/P2/P3: {priority_counts.get('P1', 0)}/{priority_counts.get('P2', 0)}/{priority_counts.get('P3', 0)}",
f"- severity high/medium/low: {severity_counts.get('high', 0)}/{severity_counts.get('medium', 0)}/{severity_counts.get('low', 0)}",
"",
"## Top queue",
"",
]
if top_entries:
lines.extend([
"| Priority | Severity | Status | Source | Timestamp | Reasons |",
"| --- | --- | --- | --- | --- | --- |",
])
for entry in top_entries:
reasons = ", ".join([reason for reason in entry.get("reasons", []) if reason]) or "none"
lines.append(
f"| {entry.get('priority', 'P3')} | {entry.get('severity', 'low')} | "
f"{entry.get('status', 'unknown')} | {entry.get('source', 'unknown')} | "
f"{entry.get('ts') or 'n/a'} | {reasons} |"
)
else:
lines.append("- no queued incident")
lines.extend(["", "## Next steps", ""])
for step in next_steps[:5]:
lines.append(f"- {step}")
out_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
shutil.copyfile(out_md, out_md.with_name("mascarade_incident_watch_latest.md"))
payload = {
"contract_version": "cockpit-v1",
"component": "mascarade-incident-watch",
"action": "render",
"status": status,
"contract_status": status,
"generated_at": generated_at,
"owner": memory_entry.get("owner", "SyncOps"),
"resume_ref": resume_ref,
"trust_level": trust_level,
"routing": routing,
"memory_entry": memory_entry,
"memory_markdown": str(memory_md) if memory_md.exists() else "",
"priority_counts": priority_counts,
"severity_counts": severity_counts,
"top_entries": top_entries,
"registry_file": str(registry_file) if registry_file.exists() else "",
"queue_file": str(queue_file) if queue_file.exists() else "",
"daily_file": str(daily_file) if daily_file.exists() else "",
"markdown_file": str(out_md),
"latest_markdown_file": str(out_md.with_name("mascarade_incident_watch_latest.md")),
"artifacts": [
str(out_md),
str(out_md.with_name("mascarade_incident_watch_latest.md")),
str(out_json),
str(out_json.with_name("mascarade_incident_watch_latest.json")),
] + [str(path) for path in (registry_file, queue_file, daily_file) if path.exists()] + ([str(memory_json)] if memory_json.exists() else []),
"degraded_reasons": [] if status == "ok" else ["incident-watch-missing-artifacts"],
"next_steps": next_steps[:5],
}
out_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
shutil.copyfile(out_json, out_json.with_name("mascarade_incident_watch_latest.json"))
print(json.dumps(payload, ensure_ascii=False, indent=2))
PY
if [[ "$OUTPUT_MODE" == "json" ]]; then
cat "$OUT_JSON"
else
cat <<EOF
Mascarade incident watch
markdown: $OUT_MD
json: $OUT_JSON
EOF
fi
@@ -0,0 +1,175 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
COCKPIT_DIR="$ROOT_DIR/artifacts/cockpit"
OUTPUT_MODE="text"
usage() {
cat <<'EOF'
Usage:
bash tools/cockpit/render_mascarade_watch_history.sh [--json]
Options:
--json Emit cockpit-v1 JSON to stdout
-h,--help Show this help
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--json)
OUTPUT_MODE="json"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
mkdir -p "$COCKPIT_DIR"
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
OUT_MD="$COCKPIT_DIR/mascarade_watch_history_${RUN_ID}.md"
OUT_JSON="$COCKPIT_DIR/mascarade_watch_history_${RUN_ID}.json"
python3 - "$COCKPIT_DIR" "$OUT_MD" "$OUT_JSON" <<'PY'
import json
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
cockpit_dir = Path(sys.argv[1])
out_md = Path(sys.argv[2])
out_json = Path(sys.argv[3])
def load_json(path: Path):
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
watch_files = sorted(
[
path
for path in cockpit_dir.glob("mascarade_incident_watch_*.json")
if "latest" not in path.name
],
key=lambda path: path.stat().st_mtime,
reverse=True,
)
memory_json = cockpit_dir / "kill_life_memory" / "latest.json"
memory_md = cockpit_dir / "kill_life_memory" / "latest.md"
memory_payload = load_json(memory_json) if memory_json.exists() else {}
memory_entry = memory_payload.get("entry", {}) if isinstance(memory_payload.get("entry"), dict) else {}
routing = memory_entry.get("routing", {}) if isinstance(memory_entry.get("routing"), dict) else {}
resume_ref = memory_payload.get("resume_ref") or memory_entry.get("resume_ref", "")
trust_level = memory_payload.get("trust_level") or memory_entry.get("trust_level", "inferred")
rows = []
for path in watch_files[:30]:
payload = load_json(path)
priority = payload.get("priority_counts", {}) if isinstance(payload.get("priority_counts"), dict) else {}
severity = payload.get("severity_counts", {}) if isinstance(payload.get("severity_counts"), dict) else {}
rows.append(
{
"generated_at": payload.get("generated_at", ""),
"status": payload.get("status", "unknown"),
"file": str(path),
"priority_counts": {
"P1": priority.get("P1", 0),
"P2": priority.get("P2", 0),
"P3": priority.get("P3", 0),
},
"severity_counts": {
"high": severity.get("high", 0),
"medium": severity.get("medium", 0),
"low": severity.get("low", 0),
},
}
)
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
status = "ok" if rows else "degraded"
lines = [
"# Mascarade watch history",
"",
f"- generated_at: {generated_at}",
f"- entry_count: {len(rows)}",
f"- trust_level: {trust_level}",
f"- resume_ref: {resume_ref or 'missing'}",
f"- selected_target: {routing.get('selected_target', 'unknown')}",
f"- kill_life_memory: {memory_md if memory_md.exists() else 'missing'}",
"",
"## History",
"",
"| Generated at | Status | P1 | P2 | P3 | High | Medium | Low | Source |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
]
if rows:
for row in rows:
lines.append(
f"| {row['generated_at'] or 'n/a'} | {row['status']} | "
f"{row['priority_counts']['P1']} | {row['priority_counts']['P2']} | {row['priority_counts']['P3']} | "
f"{row['severity_counts']['high']} | {row['severity_counts']['medium']} | {row['severity_counts']['low']} | "
f"`{row['file']}` |"
)
else:
lines.append("| n/a | degraded | 0 | 0 | 0 | 0 | 0 | 0 | `n/a` |")
out_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
shutil.copyfile(out_md, out_md.with_name("mascarade_watch_history_latest.md"))
payload = {
"contract_version": "cockpit-v1",
"component": "mascarade-watch-history",
"action": "render",
"status": status,
"contract_status": status,
"generated_at": generated_at,
"owner": memory_entry.get("owner", "SyncOps"),
"resume_ref": resume_ref,
"trust_level": trust_level,
"routing": routing,
"memory_entry": memory_entry,
"memory_markdown": str(memory_md) if memory_md.exists() else "",
"entry_count": len(rows),
"entries": rows,
"markdown_file": str(out_md),
"latest_markdown_file": str(out_md.with_name("mascarade_watch_history_latest.md")),
"artifacts": [
str(out_md),
str(out_md.with_name("mascarade_watch_history_latest.md")),
str(out_json),
str(out_json.with_name("mascarade_watch_history_latest.json")),
] + [str(row["file"]) for row in rows] + ([str(memory_json)] if memory_json.exists() else []),
"degraded_reasons": [] if rows else ["watch-history-empty"],
"next_steps": [] if rows else ["Generate at least one incident-watch artifact before reviewing history."],
}
out_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
shutil.copyfile(out_json, out_json.with_name("mascarade_watch_history_latest.json"))
print(json.dumps(payload, ensure_ascii=False, indent=2))
PY
if [[ "$OUTPUT_MODE" == "json" ]]; then
cat "$OUT_JSON"
else
cat <<EOF
Mascarade watch history
markdown: $OUT_MD
json: $OUT_JSON
EOF
fi
@@ -0,0 +1,299 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
AUDIT_JSON="${ROOT_DIR}/artifacts/cockpit/product_contract_audit/latest.json"
KILL_LIFE_JSON="${ROOT_DIR}/artifacts/cockpit/kill_life_memory/latest.json"
KILL_LIFE_MD="${ROOT_DIR}/artifacts/cockpit/kill_life_memory/latest.md"
DAILY_MD="${ROOT_DIR}/artifacts/cockpit/daily_operator_summary_latest.md"
OUTPUT_DIR="${ROOT_DIR}/artifacts/cockpit/product_contract_handoff"
JSON_ONLY=0
MARKDOWN_ONLY=0
NO_REFRESH=0
usage() {
cat <<'EOF'
Usage:
bash tools/cockpit/render_product_contract_handoff.sh [--json|--markdown|--no-refresh]
Description:
Génère un handoff produit minimal entre ops, Mascarade et kill_life.
Le handoff agrège l'audit du contrat, la mémoire latest kill_life et
la synthèse quotidienne pour fournir un seul point de reprise.
Options:
--json Affiche seulement le JSON latest.
--markdown Affiche seulement le Markdown latest.
--no-refresh Garde un mode strict lecture seule sans régénérer les prérequis légers.
-h, --help Affiche cette aide.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--json)
JSON_ONLY=1
shift
;;
--markdown)
MARKDOWN_ONLY=1
shift
;;
--no-refresh)
NO_REFRESH=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
if [[ "${JSON_ONLY}" == "1" && "${MARKDOWN_ONLY}" == "1" ]]; then
printf 'Use only one of --json or --markdown.\n' >&2
exit 2
fi
mkdir -p "${OUTPUT_DIR}"
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
JSON_FILE="${OUTPUT_DIR}/product_contract_handoff_${TIMESTAMP}.json"
MARKDOWN_FILE="${OUTPUT_DIR}/product_contract_handoff_${TIMESTAMP}.md"
LATEST_JSON="${OUTPUT_DIR}/latest.json"
LATEST_MARKDOWN="${OUTPUT_DIR}/latest.md"
python3 - "${ROOT_DIR}" "${AUDIT_JSON}" "${KILL_LIFE_JSON}" "${DAILY_MD}" "${JSON_FILE}" "${MARKDOWN_FILE}" "${NO_REFRESH}" "${TIMESTAMP}" <<'PY'
import datetime as dt
import json
import pathlib
import subprocess
import sys
root_dir = pathlib.Path(sys.argv[1])
audit_path = pathlib.Path(sys.argv[2])
kill_life_path = pathlib.Path(sys.argv[3])
daily_path = pathlib.Path(sys.argv[4])
json_out = pathlib.Path(sys.argv[5])
md_out = pathlib.Path(sys.argv[6])
no_refresh = sys.argv[7] == "1"
timestamp = sys.argv[8]
def load_json(path: pathlib.Path):
if not path.exists():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
def read_markdown_snippet(path: pathlib.Path, max_lines: int = 12):
if not path.exists():
return []
lines = path.read_text(encoding="utf-8").splitlines()
return [line for line in lines[:max_lines] if line.strip()]
def run_json_command(command):
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode != 0:
return {"status": "failed", "stderr": result.stderr.strip(), "stdout": result.stdout.strip()}
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return {"status": "failed", "stderr": result.stderr.strip(), "stdout": result.stdout.strip(), "error": "invalid-json"}
audit = load_json(audit_path)
audit_status = audit.get("status", "missing")
degraded_reasons = []
prereqs_refreshed = []
kill_life_refresh_status = "skipped"
daily_refresh_status = "skipped"
if not no_refresh and not kill_life_path.exists():
kill_life_refresh = run_json_command([
"bash",
str(root_dir / "tools/cockpit/write_kill_life_memory_entry.sh"),
"--component", "product_contract_handoff",
"--status", "ok" if audit_status == "ok" else "degraded",
"--owner", "KillLife-Bridge",
"--decision-action", "refresh-product-contract-handoff-prereqs",
"--decision-reason", "Refresh lightweight kill_life continuity so the product handoff can render without replaying a heavy lane.",
"--next-step", "Review artifacts/cockpit/product_contract_handoff/latest.md",
"--resume-ref", f"kill-life:product-contract-handoff:{timestamp}",
"--trust-level", "bounded" if audit_status == "ok" else "inferred",
"--artifact", "artifacts/cockpit/product_contract_audit/latest.json",
"--json",
])
kill_life_refresh_status = str(kill_life_refresh.get("status", "failed"))
prereqs_refreshed.append("kill_life_memory")
kill_life = load_json(kill_life_path)
entry = kill_life.get("entry", {}) if isinstance(kill_life.get("entry"), dict) else {}
routing = entry.get("routing", {}) if isinstance(entry.get("routing"), dict) else {}
decision = entry.get("decision", {}) if isinstance(entry.get("decision"), dict) else {}
memory_entry = entry.get("memory_entry", {}) if isinstance(entry.get("memory_entry"), dict) else {}
if not no_refresh and not daily_path.exists():
daily_refresh = run_json_command([
"bash",
str(root_dir / "tools/cockpit/render_daily_operator_summary.sh"),
"--json",
])
daily_refresh_status = str(daily_refresh.get("status", "failed"))
prereqs_refreshed.append("daily_operator_summary")
daily_snippet = read_markdown_snippet(daily_path)
kill_life_available = kill_life_path.exists() and bool(kill_life)
daily_available = daily_path.exists() and bool(daily_snippet)
trust_level = kill_life.get("trust_level") or entry.get("trust_level", "inferred")
resume_ref = kill_life.get("resume_ref") or entry.get("resume_ref", "missing")
owner = entry.get("owner", "unknown")
selected_target = routing.get("selected_target", "unknown")
next_step = kill_life.get("next_step") or entry.get("next_step", "Review latest daily summary")
if audit_status != "ok":
degraded_reasons.append(f"audit-{audit_status}")
if kill_life_refresh_status in {"failed", "error", "blocked"}:
degraded_reasons.append(f"kill-life-refresh-{kill_life_refresh_status}")
if daily_refresh_status in {"failed", "error", "blocked"}:
degraded_reasons.append(f"daily-refresh-{daily_refresh_status}")
if not kill_life_available:
degraded_reasons.append("missing-kill-life-latest")
if not daily_available:
degraded_reasons.append("missing-daily-summary-latest")
status = "degraded" if degraded_reasons else "ok"
contract_status = status
next_steps = []
if audit_status != "ok":
next_steps.append("Refresh product contract audit before relying on the handoff.")
if not kill_life_available:
next_steps.append("Refresh kill_life continuity memory before using the product handoff as a restart point.")
if not daily_available:
next_steps.append("Regenerate the daily operator summary so the handoff includes an operator-readable snippet.")
if not next_steps:
next_steps.append(next_step)
payload = {
"contract_version": "2026-03-21",
"component": "product-contract-handoff",
"status": status,
"contract_status": contract_status,
"generated_at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
"audit_status": audit_status,
"trust_level": trust_level,
"resume_ref": resume_ref,
"owner": owner,
"selected_target": selected_target,
"decision": decision,
"memory_entry": memory_entry,
"next_step": next_step,
"next_steps": next_steps,
"degraded_reasons": degraded_reasons,
"prereqs_refreshed": prereqs_refreshed,
"kill_life_refresh_status": kill_life_refresh_status,
"daily_refresh_status": daily_refresh_status,
"kill_life_available": kill_life_available,
"daily_summary_available": daily_available,
"daily_summary_snippet": daily_snippet,
"json_file": str(json_out),
"markdown_file": str(md_out),
"latest_json_file": str(json_out.with_name("latest.json")),
"latest_markdown_file": str(md_out.with_name("latest.md")),
"artifacts": [
"artifacts/cockpit/product_contract_audit/latest.json",
"artifacts/cockpit/kill_life_memory/latest.json",
"artifacts/cockpit/daily_operator_summary_latest.md",
"artifacts/cockpit/product_contract_handoff/latest.json",
"artifacts/cockpit/product_contract_handoff/latest.md",
],
}
json_out.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
lines = [
"# Product contract handoff",
"",
f"- generated_at: {payload['generated_at']}",
f"- audit_status: {audit_status}",
f"- trust_level: {trust_level}",
f"- resume_ref: {resume_ref}",
f"- owner: {owner}",
f"- selected_target: {selected_target}",
f"- next_step: {next_step}",
f"- degraded_reasons: {', '.join(degraded_reasons) if degraded_reasons else 'none'}",
f"- prereqs_refreshed: {', '.join(prereqs_refreshed) if prereqs_refreshed else 'none'}",
f"- kill_life_refresh_status: {kill_life_refresh_status}",
f"- daily_refresh_status: {daily_refresh_status}",
f"- kill_life_available: {kill_life_available}",
f"- daily_summary_available: {daily_available}",
]
if decision:
lines.extend([
"",
"## Decision",
"",
f"- action: {decision.get('action', 'unknown')}",
f"- reason: {decision.get('reason', 'unknown')}",
])
if memory_entry:
lines.extend([
"",
"## Continuity",
"",
f"- intent: {memory_entry.get('intent', 'unknown')}",
f"- decision_state: {memory_entry.get('decision_state', 'unknown')}",
f"- handoff: {memory_entry.get('handoff', 'unknown')}",
])
lines.extend([
"",
"## Next steps",
"",
])
for step in next_steps:
lines.append(f"- {step}")
lines.extend([
"",
"## Daily snippet",
"",
])
if daily_snippet:
for line in daily_snippet:
lines.append(f"- {line}")
else:
lines.append("- daily summary unavailable")
md_out.write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
cp "${JSON_FILE}" "${LATEST_JSON}"
cp "${MARKDOWN_FILE}" "${LATEST_MARKDOWN}"
if [[ "${JSON_ONLY}" == "1" ]]; then
cat "${LATEST_JSON}"
elif [[ "${MARKDOWN_ONLY}" == "1" ]]; then
cat "${LATEST_MARKDOWN}"
else
printf 'product_contract_handoff status=%s json=%s markdown=%s\n' \
"$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["status"])' "${LATEST_JSON}")" \
"${LATEST_JSON#${ROOT_DIR}/}" \
"${LATEST_MARKDOWN#${ROOT_DIR}/}"
fi
+393
View File
@@ -0,0 +1,393 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
OUTPUT_FILE="${ROOT_DIR}/artifacts/cockpit/weekly_refonte_summary.md"
WINDOW_DAYS=7
TASKS_FILE="${ROOT_DIR}/specs/04_tasks.md"
MACHINE_SYNC_FILE="${ROOT_DIR}/docs/MACHINE_SYNC_STATUS_2026-03-20.md"
REFONTE_LOG_DIR="${ROOT_DIR}/artifacts/refonte_tui"
CAD_LOG_DIR="${ROOT_DIR}/artifacts/cad-fusion"
COCKPIT_DIR="${ROOT_DIR}/artifacts/cockpit"
usage() {
cat <<'EOF_USAGE'
Usage: bash tools/cockpit/render_weekly_refonte_summary.sh [options]
Génère une synthèse hebdomadaire exploitable pour lopérateur et les agents.
Options:
--output FILE Fichier markdown cible. Défaut: artifacts/cockpit/weekly_refonte_summary.md
--days N Fenêtre danalyse de logs. Défaut: 7
-h, --help Aide
EOF_USAGE
}
ensure_dirs() {
mkdir -p "$(dirname "$OUTPUT_FILE")"
}
collect_files_by_mtime_desc() {
local dir="$1"
local pattern="$2"
local path
local epoch
[[ -d "$dir" ]] || return 0
while IFS= read -r -d '' path; do
if epoch="$(stat -c '%Y' "$path" 2>/dev/null)"; then
:
elif epoch="$(stat -f '%m' "$path" 2>/dev/null)"; then
:
else
epoch="0"
fi
printf '%s %s\n' "$epoch" "$path"
done < <(find "$dir" -maxdepth 1 -type f -name "$pattern" -print0)
}
latest_file() {
local dir="$1"
local pattern="$2"
collect_files_by_mtime_desc "$dir" "$pattern" | sort -nr | head -n 1 | cut -d' ' -f2-
}
recent_count() {
local dir="$1"
local pattern="$2"
if [[ ! -d "$dir" ]]; then
printf '0'
return 0
fi
find "$dir" -maxdepth 1 -type f -name "$pattern" -mtime "-${WINDOW_DAYS}" | wc -l | tr -d ' '
}
render_open_tasks() {
if [[ ! -f "$TASKS_FILE" ]]; then
printf -- '- tasks file missing: %s\n' "$TASKS_FILE"
return 0
fi
if ! rg -n "^[[:space:]]*-[[:space:]]*\\[[[:space:]]\\]" "$TASKS_FILE" | head -n 10 | while IFS= read -r line; do
[[ -z "$line" ]] && continue
printf -- '- %s\n' "$line"
done; then
printf -- '- no open task found in %s\n' "$TASKS_FILE"
fi
}
render_machine_sync() {
if [[ ! -f "$MACHINE_SYNC_FILE" ]]; then
printf -- '- machine sync file missing: %s\n' "$MACHINE_SYNC_FILE"
return 0
fi
if ! rg -n "ready|degraded|blocked|Priorit|incident|sync|delta|status" "$MACHINE_SYNC_FILE" | head -n 8 | while IFS= read -r line; do
[[ -z "$line" ]] && continue
printf -- '- %s\n' "$line"
done; then
printf -- '- no machine-sync highlight extracted\n'
fi
}
render_registry_severity() {
local file="$1"
if [[ -z "$file" || ! -f "$file" ]]; then
printf -- '- severity snapshot unavailable\n'
return 0
fi
python3 - "$file" <<'PY'
import json
import sys
from pathlib import Path
path = Path(sys.argv[1])
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
print("- severity snapshot unavailable")
raise SystemExit(0)
severity = data.get("severity_counts", {}) if isinstance(data.get("severity_counts"), dict) else {}
priority = data.get("priority_counts", {}) if isinstance(data.get("priority_counts"), dict) else {}
print(f"- severity high/medium/low: {severity.get('high', 0)}/{severity.get('medium', 0)}/{severity.get('low', 0)}")
print(f"- priority P1/P2/P3: {priority.get('P1', 0)}/{priority.get('P2', 0)}/{priority.get('P3', 0)}")
PY
}
render_log_tail() {
local file="$1"
if [[ -z "$file" || ! -f "$file" ]]; then
printf 'No log available.\n'
return 0
fi
tail -n 12 "$file"
}
render_kill_life_meta() {
local file="$1"
if [[ -z "$file" || ! -f "$file" ]]; then
printf -- '- trust_level: missing\n- resume_ref: missing\n- owner: unknown\n- selected_target: unknown\n- memory_entry: missing\n'
return 0
fi
python3 - "$file" <<'PY'
import json
import sys
from pathlib import Path
path = Path(sys.argv[1])
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
print("- trust_level: invalid")
print("- resume_ref: invalid")
print("- owner: unknown")
print("- selected_target: unknown")
print("- memory_entry: invalid")
raise SystemExit(0)
entry = data.get("entry", {}) if isinstance(data.get("entry"), dict) else {}
routing = entry.get("routing", {}) if isinstance(entry.get("routing"), dict) else {}
memory_entry = entry.get("memory_entry", {}) if isinstance(entry.get("memory_entry"), dict) else {}
handoff = memory_entry.get("handoff") if isinstance(memory_entry, dict) else None
print(f"- trust_level: {data.get('trust_level') or entry.get('trust_level', 'inferred')}")
print(f"- resume_ref: {data.get('resume_ref') or entry.get('resume_ref', 'missing')}")
print(f"- owner: {entry.get('owner', 'unknown')}")
print(f"- selected_target: {routing.get('selected_target', 'unknown')}")
print(f"- memory_entry: {handoff or memory_entry or 'missing'}")
PY
}
cad_lane_status() {
local file="$1"
if [[ -z "$file" || ! -f "$file" ]]; then
printf 'unknown'
return 0
fi
if rg -q "host entrypoint missing" "$file"; then
printf 'blocked:kicad-host-entrypoint'
return 0
fi
if rg -q "FAIL KiCad MCP host smoke" "$file"; then
printf 'blocked:kicad-host-smoke'
return 0
fi
if rg -q "FAIL " "$file"; then
printf 'degraded'
return 0
fi
printf 'ready'
}
route_next_blocking_lot() {
local cad_status="$1"
case "$cad_status" in
blocked:*|degraded)
printf 'yiacad-fusion'
;;
*)
printf 'mesh-governance'
;;
esac
}
render_routing() {
local cad_status="$1"
case "$cad_status" in
blocked:kicad-host-entrypoint)
cat <<'EOF_ROUTING'
- Exec lot prioritaire: `yiacad-fusion` tant que lentrypoint hôte KiCad manque dans `mascarade-main`.
- Lane parallèle: `mesh-governance` reste en maintien documentaire/TUI sans propagation risquée.
- Revue attendue: matérialiser `finetune/kicad_mcp_server/dist/index.js` ou décider explicitement que le fallback conteneur est un état supporté.
- Etat repo-local: scripts, TUI, snapshots et rollback sont alignés; le blocage restant n'est pas dans `Kill_LIFE`.
EOF_ROUTING
;;
blocked:kicad-host-smoke)
cat <<'EOF_ROUTING'
- Exec lot prioritaire: `yiacad-fusion` tant que `KiCad MCP host smoke` clôt sa sortie avant handshake.
- Lane parallèle: `mesh-governance` reste en maintien documentaire/TUI sans propagation risquée.
- Revue attendue: confirmer la lane `mascarade-main` du launcher KiCad puis rejouer `yiacad-fusion --action smoke`.
EOF_ROUTING
;;
degraded)
cat <<'EOF_ROUTING'
- Exec lot prioritaire: `yiacad-fusion` pour fermer les checks CAD encore dégradés.
- Lane parallèle: `mesh-governance` en maintien et synthèse.
- Revue attendue: relire le dernier log CAD et purger les artefacts périmés si besoin.
EOF_ROUTING
;;
ready)
cat <<'EOF_ROUTING'
- Exec lot prioritaire: `mesh-governance` une fois la lane CAD stable.
- Lane parallèle: `yiacad-uiux-apple-native` pour la montée P1 palette/review center.
- Revue attendue: publier la preuve opératoire YiACAD dans les trackers hebdomadaires.
EOF_ROUTING
;;
*)
cat <<'EOF_ROUTING'
- Exec lot prioritaire: `mesh-governance`.
- Lane parallèle: `yiacad-fusion`.
- Revue attendue: compléter les preuves avant propagation.
EOF_ROUTING
;;
esac
}
write_summary() {
local latest_refonte_log
local latest_cad_log
local latest_mascarade_brief
local latest_mascarade_registry
local latest_mascarade_registry_json
local latest_mascarade_queue
local latest_mascarade_watch
local latest_mascarade_watch_history
local latest_kill_life_memory_json
local latest_kill_life_memory_md
local cad_status
local next_blocking_lot
bash "${ROOT_DIR}/tools/cockpit/render_mascarade_incident_brief.sh" >/dev/null 2>&1 || true
bash "${ROOT_DIR}/tools/cockpit/mascarade_incident_registry.sh" >/dev/null 2>&1 || true
bash "${ROOT_DIR}/tools/cockpit/render_mascarade_incident_queue.sh" >/dev/null 2>&1 || true
bash "${ROOT_DIR}/tools/cockpit/render_mascarade_incident_watch.sh" >/dev/null 2>&1 || true
bash "${ROOT_DIR}/tools/cockpit/render_mascarade_watch_history.sh" >/dev/null 2>&1 || true
latest_refonte_log="$(latest_file "$REFONTE_LOG_DIR" "*.log")"
latest_cad_log="$(latest_file "$CAD_LOG_DIR" "*.log")"
latest_mascarade_brief="$(latest_file "$COCKPIT_DIR" "mascarade_incident_brief_*.md")"
latest_mascarade_registry="$(latest_file "$COCKPIT_DIR" "mascarade_incident_registry_*.md")"
latest_mascarade_registry_json="$(latest_file "$COCKPIT_DIR" "mascarade_incident_registry_*.json")"
latest_mascarade_queue="$(latest_file "$COCKPIT_DIR" "mascarade_incident_queue_*.md")"
latest_mascarade_watch="$(latest_file "$COCKPIT_DIR" "mascarade_incident_watch_*.md")"
latest_mascarade_watch_history="$(latest_file "$COCKPIT_DIR" "mascarade_watch_history_*.md")"
latest_kill_life_memory_json="${COCKPIT_DIR}/kill_life_memory/latest.json"
latest_kill_life_memory_md="${COCKPIT_DIR}/kill_life_memory/latest.md"
cad_status="$(cad_lane_status "$latest_cad_log")"
next_blocking_lot="$(route_next_blocking_lot "$cad_status")"
cat >"$OUTPUT_FILE" <<EOF
# Weekly Refonte Summary
- generated_at: $(date '+%Y-%m-%d %H:%M:%S %z')
- window_days: ${WINDOW_DAYS}
- next_blocking_lot: ${next_blocking_lot}
- parallel_design_lane: yiacad-uiux-apple-native
- refonte_logs_recent: $(recent_count "$REFONTE_LOG_DIR" "*.log")
- cad_logs_recent: $(recent_count "$CAD_LOG_DIR" "*.log")
- latest_refonte_log: ${latest_refonte_log:-none}
- latest_cad_log: ${latest_cad_log:-none}
- latest_cad_status: ${cad_status}
- latest_mascarade_brief: ${latest_mascarade_brief:-none}
- latest_mascarade_registry: ${latest_mascarade_registry:-none}
- latest_mascarade_queue: ${latest_mascarade_queue:-none}
- latest_mascarade_watch: ${latest_mascarade_watch:-none}
- latest_mascarade_watch_history: ${latest_mascarade_watch_history:-none}
- latest_kill_life_memory: ${latest_kill_life_memory_md:-none}
## Open tasks
$(render_open_tasks)
## Machine sync highlights
$(render_machine_sync)
## Latest refonte log tail
\`\`\`text
$(render_log_tail "$latest_refonte_log")
\`\`\`
## Latest YiACAD log tail
\`\`\`text
$(render_log_tail "$latest_cad_log")
\`\`\`
## Latest Mascarade incident brief
\`\`\`text
$(render_log_tail "$latest_mascarade_brief")
\`\`\`
## Mascarade incident registry
$(render_registry_severity "$latest_mascarade_registry_json")
\`\`\`text
$(render_log_tail "$latest_mascarade_registry")
\`\`\`
## Mascarade incident queue
\`\`\`text
$(render_log_tail "$latest_mascarade_queue")
\`\`\`
## Mascarade incident watch
\`\`\`text
$(render_log_tail "$latest_mascarade_watch")
\`\`\`
## Mascarade watch history
\`\`\`text
$(render_log_tail "$latest_mascarade_watch_history")
\`\`\`
## Kill life execution continuity
$(render_kill_life_meta "$latest_kill_life_memory_json")
\`\`\`text
$(render_log_tail "$latest_kill_life_memory_md")
\`\`\`
## Routing
$(render_routing "$cad_status")
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--output)
OUTPUT_FILE="${2:-}"
shift 2
;;
--days)
WINDOW_DAYS="${2:-7}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Option inconnue: $1" >&2
usage
exit 2
;;
esac
done
ensure_dirs
write_summary
cat "$OUTPUT_FILE"
+794
View File
@@ -0,0 +1,794 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="${ROOT_DIR:-${SCRIPT_DIR%/tools/cockpit}}"
source "${ROOT_DIR}/tools/cockpit/json_contract.sh"
LOG_DIR="$ROOT_DIR/artifacts/cockpit"
PERSIST_DAYS=14
REGISTRY_FILE="${ROOT_DIR}/specs/contracts/machine_registry.mesh.json"
usage() {
cat <<'USAGE'
Usage:
bash tools/cockpit/run_alignment_daily.sh [options]
Runbook de vérification quotidienne de l'alignement SSH + état repo.
Options:
--json Affiche un résumé JSON (en plus du log console)
--skip-healthcheck Exécute uniquement le rafraîchissement repo (utile hors machine de pilotage).
--skip-mesh Exécute uniquement health-check + refresh repo sans préflight mesh.
--skip-mascarade-health
Ignore volontairement le health-check live Mascarade/Ollama.
--mesh-load-profile <tower-first|photon-safe>
Active la stratégie de charge pour le préflight mesh.
- tower-first (défaut): clems -> kxkm -> cils -> local -> root (réserve)
- photon-safe: idem, avec non-essentiel désactivé sur CILS et pas de précheck applicatif CILS.
Le plan P2P priorise systématiquement Tower puis KXKM avant CILS (quota) puis local.
--purge-days N Conserve les logs de ce script sur N jours (défaut: 14)
--no-purge Désactive la purge automatique des logs
--skip-log-ops Ignore volontairement le résumé purgatif log_ops (JSON).
-h, --help Affiche cette aide
USAGE
}
json_output=false
skip_healthcheck=false
skip_mesh=false
do_purge=true
MESH_LOAD_PROFILE="tower-first"
SKIP_MASCARADE_HEALTH=false
MASCARADE_HEALTH_FILE=""
MASCARADE_HEALTH_STATUS="n/a"
MASCARADE_RUNTIME_STATUS="unknown"
MASCARADE_PROVIDER="unknown"
MASCARADE_MODEL="unknown"
MASCARADE_LOGS_FILE=""
MASCARADE_LOGS_STATUS="n/a"
MASCARADE_BRIEF_FILE=""
MASCARADE_BRIEF_STATUS="n/a"
MASCARADE_BRIEF_MARKDOWN=""
MASCARADE_REGISTRY_FILE=""
MASCARADE_REGISTRY_STATUS="n/a"
MASCARADE_REGISTRY_MARKDOWN=""
MASCARADE_QUEUE_FILE=""
MASCARADE_QUEUE_STATUS="n/a"
MASCARADE_QUEUE_MARKDOWN=""
MASCARADE_WATCH_FILE=""
MASCARADE_WATCH_STATUS="n/a"
MASCARADE_WATCH_MARKDOWN=""
MASCARADE_WATCH_HISTORY_FILE=""
MASCARADE_WATCH_HISTORY_STATUS="n/a"
MASCARADE_WATCH_HISTORY_MARKDOWN=""
MASCARADE_ROUTING_FILE=""
MASCARADE_ROUTING_STATUS="n/a"
DAILY_OPERATOR_SUMMARY_FILE=""
DAILY_OPERATOR_SUMMARY_STATUS="n/a"
DAILY_OPERATOR_SUMMARY_MARKDOWN=""
KILL_LIFE_MEMORY_FILE=""
KILL_LIFE_MEMORY_STATUS="n/a"
KILL_LIFE_MEMORY_MARKDOWN=""
TRUST_LEVEL="inferred"
RESUME_REF=""
SKIP_LOG_OPS=false
LOG_OPS_SUMMARY_FILE=""
LOG_OPS_PURGE_FILE=""
LOG_OPS_SUMMARY_STATUS="n/a"
LOG_OPS_PURGE_STATUS="n/a"
LOG_OPS_STALE="0"
LOG_OPS_PURGED="0"
REGISTRY_SUMMARY_FILE=""
REGISTRY_SUMMARY_STATUS="n/a"
REGISTRY_TARGET_COUNT="0"
REGISTRY_DEFAULT_PROFILE=""
OVERALL_STATUS="ok"
json_field() {
local file="$1"
local field="$2"
local raw=""
raw="$(sed -n "s/.*\"${field}\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" "${file}" | head -n1 || true)"
if [[ -z "${raw}" ]]; then
raw="$(sed -n "s/.*\"${field}\"[[:space:]]*:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p" "${file}" | head -n1 || true)"
fi
printf '%s' "${raw}"
}
json_inline_object() {
local file="$1"
local field="${2:-}"
python3 - "$file" "$field" <<'PY'
import json
import sys
from pathlib import Path
path = Path(sys.argv[1])
field = sys.argv[2]
payload = {}
if path.exists():
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
payload = {"status": "invalid-json", "file": str(path)}
if field:
value = payload.get(field, {}) if isinstance(payload, dict) else {}
else:
value = payload
print(json.dumps(value if isinstance(value, (dict, list)) else {}, ensure_ascii=True))
PY
}
json_decision_object() {
local action="$1"
local reason="$2"
python3 - "$action" "$reason" <<'PY'
import json
import sys
print(json.dumps({"action": sys.argv[1], "reason": sys.argv[2]}, ensure_ascii=True))
PY
}
while [[ $# -gt 0 ]]; do
case "$1" in
--json) json_output=true; shift ;;
--skip-healthcheck) skip_healthcheck=true; shift ;;
--skip-mesh) skip_mesh=true; shift ;;
--skip-mascarade-health)
SKIP_MASCARADE_HEALTH=true
shift
;;
--mesh-load-profile)
MESH_LOAD_PROFILE="${2:-}"
if [[ -z "${MESH_LOAD_PROFILE}" || ! "${MESH_LOAD_PROFILE}" =~ ^(tower-first|photon-safe)$ ]]; then
echo "[error] --mesh-load-profile requires tower-first|photon-safe" >&2
exit 1
fi
shift 2
;;
--purge-days)
PERSIST_DAYS="${2:-}"
if ! [[ "$PERSIST_DAYS" =~ ^[0-9]+$ ]]; then
echo "[error] --purge-days requires an integer" >&2
exit 1
fi
shift 2
;;
--no-purge) do_purge=false; shift ;;
--skip-log-ops)
SKIP_LOG_OPS=true
shift
;;
-h|--help) usage; exit 0 ;;
*) echo "[error] option inconnue: $1" >&2; usage; exit 1 ;;
esac
done
mkdir -p "$LOG_DIR"
timestamp="$(date '+%Y%m%d_%H%M%S')"
log_file="$LOG_DIR/machine_alignment_daily_${timestamp}.log"
summary_file="${LOG_DIR}/machine_alignment_daily_latest.log"
start_ts="$(date -u '+%Y-%m-%d %H:%M:%S UTC')"
log() {
printf '%s\n' "$*" | tee -a "$log_file"
}
run_step() {
local label="$1"
shift
log "[run] $label"
if "$@" >>"$log_file" 2>&1; then
log "[ok] $label"
return 0
else
local code=$?
log "[ko] $label (exit=$code)"
return $code
fi
}
run_step_capture() {
local label="$1"
local capture_file="$2"
shift 2
log "[run] $label"
if "$@" >"${capture_file}" 2>&1; then
log "[ok] $label"
return 0
else
local code=$?
log "[ko] $label (exit=$code)"
return $code
fi
}
status=0
: >"$log_file"
(
echo "generated_at=${start_ts}"
echo "runner=run_alignment_daily"
echo "root_dir=$ROOT_DIR"
) >>"$log_file"
if $skip_healthcheck; then
log "[skip] healthcheck_json"
else
run_step "healthcheck_json" bash "$ROOT_DIR/tools/cockpit/ssh_healthcheck.sh" --json || status=1
fi
REGISTRY_SUMMARY_FILE="$LOG_DIR/machine_registry_summary_${timestamp}.json"
if run_step_capture "machine_registry_summary_json" "${REGISTRY_SUMMARY_FILE}" \
bash "$ROOT_DIR/tools/cockpit/machine_registry.sh" --action summary --json; then
REGISTRY_SUMMARY_STATUS="$(json_field "${REGISTRY_SUMMARY_FILE}" status)"
REGISTRY_TARGET_COUNT="$(json_field "${REGISTRY_SUMMARY_FILE}" target_count)"
REGISTRY_DEFAULT_PROFILE="$(json_field "${REGISTRY_SUMMARY_FILE}" default_profile)"
else
status=1
REGISTRY_SUMMARY_STATUS="failed"
REGISTRY_TARGET_COUNT="0"
REGISTRY_DEFAULT_PROFILE=""
fi
if [[ -z "${REGISTRY_TARGET_COUNT}" ]]; then
REGISTRY_TARGET_COUNT="0"
fi
if [[ -z "${REGISTRY_DEFAULT_PROFILE}" ]]; then
REGISTRY_DEFAULT_PROFILE="unknown"
fi
run_step "repo_refresh_header" bash "$ROOT_DIR/tools/repo_state/repo_refresh.sh" --header-only || status=1
if $SKIP_MASCARADE_HEALTH; then
log "[skip] mascarade_runtime_health_json"
MASCARADE_HEALTH_STATUS="skipped"
MASCARADE_RUNTIME_STATUS="skipped"
MASCARADE_LOGS_STATUS="skipped"
MASCARADE_BRIEF_STATUS="skipped"
MASCARADE_REGISTRY_STATUS="skipped"
MASCARADE_QUEUE_STATUS="skipped"
MASCARADE_WATCH_STATUS="skipped"
MASCARADE_WATCH_HISTORY_STATUS="skipped"
else
MASCARADE_HEALTH_FILE="$LOG_DIR/mascarade_runtime_health_${timestamp}.json"
if run_step_capture "mascarade_runtime_health_json" "${MASCARADE_HEALTH_FILE}" \
bash "$ROOT_DIR/tools/cockpit/mascarade_runtime_health.sh" --json; then
MASCARADE_HEALTH_STATUS="$(json_field "${MASCARADE_HEALTH_FILE}" status)"
MASCARADE_RUNTIME_STATUS="$(json_field "${MASCARADE_HEALTH_FILE}" runtime_status)"
MASCARADE_PROVIDER="$(json_field "${MASCARADE_HEALTH_FILE}" provider)"
MASCARADE_MODEL="$(json_field "${MASCARADE_HEALTH_FILE}" model)"
else
MASCARADE_HEALTH_STATUS="failed"
MASCARADE_RUNTIME_STATUS="failed"
status=1
fi
MASCARADE_LOGS_FILE="$LOG_DIR/mascarade_logs_latest_${timestamp}.json"
if run_step_capture "mascarade_logs_latest_json" "${MASCARADE_LOGS_FILE}" \
bash "$ROOT_DIR/tools/cockpit/mascarade_logs_tui.sh" --action latest --json; then
MASCARADE_LOGS_STATUS="$(json_field "${MASCARADE_LOGS_FILE}" status)"
else
MASCARADE_LOGS_STATUS="failed"
fi
MASCARADE_BRIEF_FILE="$LOG_DIR/mascarade_incident_brief_${timestamp}.json"
if run_step_capture "mascarade_incident_brief_json" "${MASCARADE_BRIEF_FILE}" \
bash "$ROOT_DIR/tools/cockpit/render_mascarade_incident_brief.sh" --json; then
MASCARADE_BRIEF_STATUS="$(json_field "${MASCARADE_BRIEF_FILE}" status)"
MASCARADE_BRIEF_MARKDOWN="$(json_field "${MASCARADE_BRIEF_FILE}" markdown_file)"
else
MASCARADE_BRIEF_STATUS="failed"
fi
MASCARADE_REGISTRY_FILE="$LOG_DIR/mascarade_incident_registry_${timestamp}.json"
if run_step_capture "mascarade_incident_registry_json" "${MASCARADE_REGISTRY_FILE}" \
bash "$ROOT_DIR/tools/cockpit/mascarade_incident_registry.sh" --json; then
MASCARADE_REGISTRY_STATUS="$(json_field "${MASCARADE_REGISTRY_FILE}" status)"
MASCARADE_REGISTRY_MARKDOWN="$(json_field "${MASCARADE_REGISTRY_FILE}" markdown_file)"
else
MASCARADE_REGISTRY_STATUS="failed"
fi
MASCARADE_QUEUE_FILE="$LOG_DIR/mascarade_incident_queue_${timestamp}.json"
if run_step_capture "mascarade_incident_queue_json" "${MASCARADE_QUEUE_FILE}" \
bash "$ROOT_DIR/tools/cockpit/render_mascarade_incident_queue.sh" --json; then
MASCARADE_QUEUE_STATUS="$(json_field "${MASCARADE_QUEUE_FILE}" status)"
MASCARADE_QUEUE_MARKDOWN="$(json_field "${MASCARADE_QUEUE_FILE}" markdown_file)"
else
MASCARADE_QUEUE_STATUS="failed"
fi
fi
mesh_status="skipped"
mesh_json_file=""
if $skip_mesh; then
log "[skip] mesh_sync_preflight_json"
else
mesh_json_file="$LOG_DIR/machine_alignment_mesh_preflight_${timestamp}.json"
if run_step_capture "mesh_sync_preflight_json" "${mesh_json_file}" \
bash "$ROOT_DIR/tools/cockpit/mesh_sync_preflight.sh" --json --load-profile "${MESH_LOAD_PROFILE}"; then
mesh_status="$(sed -n 's/.*"mesh_status"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$mesh_json_file" | head -n 1)"
else
status=1
mesh_status="failed"
fi
fi
if $SKIP_LOG_OPS; then
log "[skip] log_ops_summary_json"
LOG_OPS_SUMMARY_STATUS="skipped"
LOG_OPS_PURGE_STATUS="skipped"
else
LOG_OPS_SUMMARY_FILE="$LOG_DIR/machine_alignment_log_ops_summary_${timestamp}.json"
if run_step_capture "log_ops_summary_json" "${LOG_OPS_SUMMARY_FILE}" \
bash "$ROOT_DIR/tools/cockpit/log_ops.sh" --action summary --json --retention-days "$PERSIST_DAYS"; then
LOG_OPS_SUMMARY_STATUS="$(json_field "${LOG_OPS_SUMMARY_FILE}" status)"
LOG_OPS_STALE="$(json_field "${LOG_OPS_SUMMARY_FILE}" stale)"
else
status=1
LOG_OPS_SUMMARY_STATUS="failed"
LOG_OPS_STALE="0"
fi
if [[ -z "${LOG_OPS_STALE}" ]]; then
LOG_OPS_STALE="0"
fi
LOG_OPS_PURGE_FILE="$LOG_DIR/machine_alignment_log_ops_purge_${timestamp}.json"
if $do_purge; then
if run_step_capture "log_ops_purge_json" "${LOG_OPS_PURGE_FILE}" \
bash "$ROOT_DIR/tools/cockpit/log_ops.sh" --action purge --apply --retention-days "$PERSIST_DAYS" --json; then
LOG_OPS_PURGE_STATUS="$(json_field "${LOG_OPS_PURGE_FILE}" status)"
LOG_OPS_PURGED="$(json_field "${LOG_OPS_PURGE_FILE}" purged_count)"
else
LOG_OPS_PURGE_STATUS="failed"
LOG_OPS_PURGED="0"
status=1
fi
else
if run_step_capture "log_ops_purge_dry_json" "${LOG_OPS_PURGE_FILE}" \
bash "$ROOT_DIR/tools/cockpit/log_ops.sh" --action purge --retention-days "$PERSIST_DAYS" --json; then
LOG_OPS_PURGE_STATUS="$(json_field "${LOG_OPS_PURGE_FILE}" status)"
LOG_OPS_PURGED="$(json_field "${LOG_OPS_PURGE_FILE}" purged_count)"
else
LOG_OPS_PURGE_STATUS="failed"
LOG_OPS_PURGED="0"
status=1
fi
fi
fi
if [[ -z "$mesh_status" ]]; then
mesh_status="unknown"
fi
if [[ -z "$LOG_OPS_SUMMARY_STATUS" ]]; then
LOG_OPS_SUMMARY_STATUS="unknown"
fi
if [[ -z "$LOG_OPS_PURGE_STATUS" ]]; then
LOG_OPS_PURGE_STATUS="unknown"
fi
if [[ -z "$MASCARADE_HEALTH_STATUS" ]]; then
MASCARADE_HEALTH_STATUS="unknown"
fi
if [[ -z "$MASCARADE_RUNTIME_STATUS" ]]; then
MASCARADE_RUNTIME_STATUS="unknown"
fi
if [[ -z "$MASCARADE_PROVIDER" ]]; then
MASCARADE_PROVIDER="unknown"
fi
if [[ -z "$MASCARADE_MODEL" ]]; then
MASCARADE_MODEL="unknown"
fi
if [[ -z "$MASCARADE_LOGS_STATUS" ]]; then
MASCARADE_LOGS_STATUS="unknown"
fi
if [[ -z "$MASCARADE_BRIEF_STATUS" ]]; then
MASCARADE_BRIEF_STATUS="unknown"
fi
if [[ -z "$MASCARADE_REGISTRY_STATUS" ]]; then
MASCARADE_REGISTRY_STATUS="unknown"
fi
if [[ -z "$MASCARADE_QUEUE_STATUS" ]]; then
MASCARADE_QUEUE_STATUS="unknown"
fi
if [[ -z "$MASCARADE_WATCH_STATUS" ]]; then
MASCARADE_WATCH_STATUS="unknown"
fi
if [[ -z "$MASCARADE_WATCH_HISTORY_STATUS" ]]; then
MASCARADE_WATCH_HISTORY_STATUS="unknown"
fi
if (( status == 0 )); then
result="ok"
else
result="ko"
fi
if [[ "${mesh_status}" == "degraded" || "${LOG_OPS_SUMMARY_STATUS}" == "degraded" || "${LOG_OPS_PURGE_STATUS}" == "degraded" || "${MASCARADE_HEALTH_STATUS}" == "degraded" || "${MASCARADE_LOGS_STATUS}" == "degraded" || "${MASCARADE_BRIEF_STATUS}" == "degraded" || "${MASCARADE_REGISTRY_STATUS}" == "degraded" || "${MASCARADE_QUEUE_STATUS}" == "degraded" || "${DAILY_OPERATOR_SUMMARY_STATUS}" == "degraded" ]]; then
OVERALL_STATUS="degraded"
elif [[ "${mesh_status}" == "blocked" || "${LOG_OPS_SUMMARY_STATUS}" == "blocked" || "${LOG_OPS_PURGE_STATUS}" == "blocked" || "${MASCARADE_HEALTH_STATUS}" == "blocked" || "${MASCARADE_HEALTH_STATUS}" == "failed" || "${MASCARADE_LOGS_STATUS}" == "blocked" || "${MASCARADE_LOGS_STATUS}" == "failed" || "${MASCARADE_BRIEF_STATUS}" == "blocked" || "${MASCARADE_BRIEF_STATUS}" == "failed" || "${MASCARADE_REGISTRY_STATUS}" == "blocked" || "${MASCARADE_REGISTRY_STATUS}" == "failed" || "${MASCARADE_QUEUE_STATUS}" == "blocked" || "${MASCARADE_QUEUE_STATUS}" == "failed" || "${DAILY_OPERATOR_SUMMARY_STATUS}" == "blocked" || "${DAILY_OPERATOR_SUMMARY_STATUS}" == "failed" || "${result}" == "ko" ]]; then
OVERALL_STATUS="ko"
fi
if [[ "${result}" == "ko" ]]; then
OVERALL_STATUS="ko"
fi
if [[ "${OVERALL_STATUS}" == "ok" ]]; then
result="ok"
elif [[ "${OVERALL_STATUS}" == "degraded" ]]; then
result="degraded"
else
result="ko"
fi
artifacts=("${log_file}" "${summary_file}")
degraded_reasons=()
next_steps=()
if [[ -n "${REGISTRY_SUMMARY_FILE}" ]]; then
artifacts+=("${REGISTRY_SUMMARY_FILE}")
fi
if [[ -n "${MASCARADE_HEALTH_FILE}" ]]; then
artifacts+=("${MASCARADE_HEALTH_FILE}")
fi
if [[ -n "${MASCARADE_LOGS_FILE}" ]]; then
artifacts+=("${MASCARADE_LOGS_FILE}")
fi
if [[ -n "${MASCARADE_BRIEF_FILE}" ]]; then
artifacts+=("${MASCARADE_BRIEF_FILE}")
fi
if [[ -n "${MASCARADE_REGISTRY_FILE}" ]]; then
artifacts+=("${MASCARADE_REGISTRY_FILE}")
fi
if [[ -n "${MASCARADE_QUEUE_FILE}" ]]; then
artifacts+=("${MASCARADE_QUEUE_FILE}")
fi
if [[ -n "${DAILY_OPERATOR_SUMMARY_FILE}" ]]; then
artifacts+=("${DAILY_OPERATOR_SUMMARY_FILE}")
fi
if [[ -n "${mesh_json_file}" ]]; then
artifacts+=("${mesh_json_file}")
fi
if [[ -n "${LOG_OPS_SUMMARY_FILE}" ]]; then
artifacts+=("${LOG_OPS_SUMMARY_FILE}")
fi
if [[ -n "${LOG_OPS_PURGE_FILE}" ]]; then
artifacts+=("${LOG_OPS_PURGE_FILE}")
fi
if [[ "${mesh_status}" == "degraded" || "${mesh_status}" == "failed" || "${mesh_status}" == "blocked" ]]; then
degraded_reasons+=("mesh-${mesh_status}")
next_steps+=("bash tools/cockpit/mesh_health_check.sh --json --load-profile ${MESH_LOAD_PROFILE}")
fi
if [[ "${REGISTRY_SUMMARY_STATUS}" == "degraded" || "${REGISTRY_SUMMARY_STATUS}" == "failed" || "${REGISTRY_SUMMARY_STATUS}" == "blocked" ]]; then
degraded_reasons+=("machine-registry-${REGISTRY_SUMMARY_STATUS}")
next_steps+=("bash tools/cockpit/machine_registry.sh --action summary --json")
fi
if [[ "${MASCARADE_HEALTH_STATUS}" == "degraded" || "${MASCARADE_HEALTH_STATUS}" == "failed" || "${MASCARADE_HEALTH_STATUS}" == "blocked" ]]; then
degraded_reasons+=("mascarade-runtime-${MASCARADE_HEALTH_STATUS}")
next_steps+=("bash tools/cockpit/mascarade_runtime_health.sh --json")
fi
if [[ "${MASCARADE_LOGS_STATUS}" == "degraded" || "${MASCARADE_LOGS_STATUS}" == "failed" || "${MASCARADE_LOGS_STATUS}" == "blocked" ]]; then
degraded_reasons+=("mascarade-logs-${MASCARADE_LOGS_STATUS}")
next_steps+=("bash tools/cockpit/mascarade_logs_tui.sh --action latest --json")
fi
if [[ "${MASCARADE_BRIEF_STATUS}" == "degraded" || "${MASCARADE_BRIEF_STATUS}" == "failed" || "${MASCARADE_BRIEF_STATUS}" == "blocked" ]]; then
degraded_reasons+=("mascarade-brief-${MASCARADE_BRIEF_STATUS}")
next_steps+=("bash tools/cockpit/render_mascarade_incident_brief.sh --json")
fi
if [[ "${MASCARADE_REGISTRY_STATUS}" == "degraded" || "${MASCARADE_REGISTRY_STATUS}" == "failed" || "${MASCARADE_REGISTRY_STATUS}" == "blocked" ]]; then
degraded_reasons+=("mascarade-registry-${MASCARADE_REGISTRY_STATUS}")
next_steps+=("bash tools/cockpit/mascarade_incident_registry.sh --json")
fi
if [[ "${MASCARADE_QUEUE_STATUS}" == "degraded" || "${MASCARADE_QUEUE_STATUS}" == "failed" || "${MASCARADE_QUEUE_STATUS}" == "blocked" ]]; then
degraded_reasons+=("mascarade-queue-${MASCARADE_QUEUE_STATUS}")
next_steps+=("bash tools/cockpit/render_mascarade_incident_queue.sh --json")
fi
if [[ "${DAILY_OPERATOR_SUMMARY_STATUS}" == "degraded" || "${DAILY_OPERATOR_SUMMARY_STATUS}" == "failed" || "${DAILY_OPERATOR_SUMMARY_STATUS}" == "blocked" ]]; then
degraded_reasons+=("daily-operator-summary-${DAILY_OPERATOR_SUMMARY_STATUS}")
next_steps+=("bash tools/cockpit/render_daily_operator_summary.sh --json")
fi
if [[ "${LOG_OPS_SUMMARY_STATUS}" == "degraded" || "${LOG_OPS_SUMMARY_STATUS}" == "failed" || "${LOG_OPS_SUMMARY_STATUS}" == "blocked" ]]; then
degraded_reasons+=("log-ops-summary-${LOG_OPS_SUMMARY_STATUS}")
next_steps+=("bash tools/cockpit/log_ops.sh --action summary --retention-days ${PERSIST_DAYS} --json")
fi
if [[ "${LOG_OPS_PURGE_STATUS}" == "degraded" || "${LOG_OPS_PURGE_STATUS}" == "failed" || "${LOG_OPS_PURGE_STATUS}" == "blocked" ]]; then
degraded_reasons+=("log-ops-purge-${LOG_OPS_PURGE_STATUS}")
next_steps+=("bash tools/cockpit/log_ops.sh --action purge --retention-days ${PERSIST_DAYS} --apply --json")
fi
if [[ "${result}" == "ko" ]]; then
degraded_reasons+=("alignment-run-failed")
next_steps+=("Inspect ${log_file} for the failing step and rerun the degraded-safe checks.")
fi
if $do_purge; then
find "$LOG_DIR" -type f -name 'machine_alignment_daily_*.log' -mtime +"$PERSIST_DAYS" -delete || true
fi
cp "$log_file" "$summary_file"
{
echo "[summary] result=$result"
echo "[summary] mesh_status=$mesh_status"
echo "[summary] mesh_load_profile=$MESH_LOAD_PROFILE"
if [[ -n "$mesh_json_file" ]]; then
echo "[summary] mesh_json_file=$mesh_json_file"
fi
echo "[summary] registry_summary_file=$REGISTRY_SUMMARY_FILE"
echo "[summary] registry_summary_status=$REGISTRY_SUMMARY_STATUS"
echo "[summary] registry_target_count=$REGISTRY_TARGET_COUNT"
echo "[summary] registry_default_profile=$REGISTRY_DEFAULT_PROFILE"
echo "[summary] mascarade_health_status=$MASCARADE_HEALTH_STATUS"
echo "[summary] mascarade_runtime_status=$MASCARADE_RUNTIME_STATUS"
echo "[summary] mascarade_provider=$MASCARADE_PROVIDER"
echo "[summary] mascarade_model=$MASCARADE_MODEL"
echo "[summary] mascarade_health_file=$MASCARADE_HEALTH_FILE"
echo "[summary] mascarade_logs_status=$MASCARADE_LOGS_STATUS"
echo "[summary] mascarade_logs_file=$MASCARADE_LOGS_FILE"
echo "[summary] mascarade_brief_status=$MASCARADE_BRIEF_STATUS"
echo "[summary] mascarade_brief_file=$MASCARADE_BRIEF_FILE"
echo "[summary] mascarade_brief_markdown=$MASCARADE_BRIEF_MARKDOWN"
echo "[summary] mascarade_registry_status=$MASCARADE_REGISTRY_STATUS"
echo "[summary] mascarade_registry_file=$MASCARADE_REGISTRY_FILE"
echo "[summary] mascarade_registry_markdown=$MASCARADE_REGISTRY_MARKDOWN"
echo "[summary] mascarade_queue_status=$MASCARADE_QUEUE_STATUS"
echo "[summary] mascarade_queue_file=$MASCARADE_QUEUE_FILE"
echo "[summary] mascarade_queue_markdown=$MASCARADE_QUEUE_MARKDOWN"
echo "[summary] log_ops_summary_status=$LOG_OPS_SUMMARY_STATUS"
echo "[summary] log_ops_stale=$LOG_OPS_STALE"
echo "[summary] log_ops_purge_status=$LOG_OPS_PURGE_STATUS"
echo "[summary] log_ops_purged=$LOG_OPS_PURGED"
echo "[summary] log_ops_summary_file=$LOG_OPS_SUMMARY_FILE"
echo "[summary] log_ops_purge_file=$LOG_OPS_PURGE_FILE"
echo "[summary] log_file=$log_file"
echo "[summary] latest_log=$summary_file"
echo "[summary] timestamp=$timestamp"
} | tee -a "$log_file"
DAILY_OPERATOR_SUMMARY_FILE="$LOG_DIR/daily_operator_summary_${timestamp}.json"
if bash "$ROOT_DIR/tools/cockpit/render_daily_operator_summary.sh" \
--daily-log "$log_file" \
--brief-markdown "$MASCARADE_BRIEF_MARKDOWN" \
--registry-markdown "$MASCARADE_REGISTRY_MARKDOWN" \
--queue-markdown "$MASCARADE_QUEUE_MARKDOWN" \
--json >"$DAILY_OPERATOR_SUMMARY_FILE" 2>>"$log_file"; then
DAILY_OPERATOR_SUMMARY_STATUS="$(json_field "${DAILY_OPERATOR_SUMMARY_FILE}" status)"
DAILY_OPERATOR_SUMMARY_MARKDOWN="$(json_field "${DAILY_OPERATOR_SUMMARY_FILE}" markdown_file)"
else
DAILY_OPERATOR_SUMMARY_STATUS="failed"
fi
MASCARADE_WATCH_FILE="$LOG_DIR/mascarade_incident_watch_${timestamp}.json"
if bash "$ROOT_DIR/tools/cockpit/render_mascarade_incident_watch.sh" --json >"$MASCARADE_WATCH_FILE" 2>>"$log_file"; then
MASCARADE_WATCH_STATUS="$(json_field "${MASCARADE_WATCH_FILE}" status)"
MASCARADE_WATCH_MARKDOWN="$(json_field "${MASCARADE_WATCH_FILE}" markdown_file)"
else
MASCARADE_WATCH_STATUS="failed"
fi
{
echo "[summary] daily_operator_summary_status=$DAILY_OPERATOR_SUMMARY_STATUS"
echo "[summary] daily_operator_summary_file=$DAILY_OPERATOR_SUMMARY_FILE"
echo "[summary] daily_operator_summary_markdown=$DAILY_OPERATOR_SUMMARY_MARKDOWN"
echo "[summary] mascarade_watch_status=$MASCARADE_WATCH_STATUS"
echo "[summary] mascarade_watch_file=$MASCARADE_WATCH_FILE"
echo "[summary] mascarade_watch_markdown=$MASCARADE_WATCH_MARKDOWN"
} | tee -a "$log_file"
MASCARADE_WATCH_HISTORY_FILE="$LOG_DIR/mascarade_watch_history_${timestamp}.json"
if bash "$ROOT_DIR/tools/cockpit/render_mascarade_watch_history.sh" --json >"$MASCARADE_WATCH_HISTORY_FILE" 2>>"$log_file"; then
MASCARADE_WATCH_HISTORY_STATUS="$(json_field "${MASCARADE_WATCH_HISTORY_FILE}" status)"
MASCARADE_WATCH_HISTORY_MARKDOWN="$(json_field "${MASCARADE_WATCH_HISTORY_FILE}" markdown_file)"
else
MASCARADE_WATCH_HISTORY_STATUS="failed"
fi
{
echo "[summary] mascarade_watch_history_status=$MASCARADE_WATCH_HISTORY_STATUS"
echo "[summary] mascarade_watch_history_file=$MASCARADE_WATCH_HISTORY_FILE"
echo "[summary] mascarade_watch_history_markdown=$MASCARADE_WATCH_HISTORY_MARKDOWN"
} | tee -a "$log_file"
if [[ "${DAILY_OPERATOR_SUMMARY_STATUS}" == "degraded" && "${OVERALL_STATUS}" == "ok" ]]; then
OVERALL_STATUS="degraded"
fi
if [[ "${DAILY_OPERATOR_SUMMARY_STATUS}" == "blocked" || "${DAILY_OPERATOR_SUMMARY_STATUS}" == "failed" ]]; then
OVERALL_STATUS="ko"
fi
if [[ -n "${DAILY_OPERATOR_SUMMARY_FILE}" ]]; then
artifacts+=("${DAILY_OPERATOR_SUMMARY_FILE}")
fi
if [[ "${DAILY_OPERATOR_SUMMARY_STATUS}" == "degraded" || "${DAILY_OPERATOR_SUMMARY_STATUS}" == "failed" || "${DAILY_OPERATOR_SUMMARY_STATUS}" == "blocked" ]]; then
degraded_reasons+=("daily-operator-summary-${DAILY_OPERATOR_SUMMARY_STATUS}")
next_steps+=("bash tools/cockpit/render_daily_operator_summary.sh --json")
fi
if [[ "${MASCARADE_WATCH_STATUS}" == "degraded" && "${OVERALL_STATUS}" == "ok" ]]; then
OVERALL_STATUS="degraded"
fi
if [[ "${MASCARADE_WATCH_STATUS}" == "blocked" || "${MASCARADE_WATCH_STATUS}" == "failed" ]]; then
OVERALL_STATUS="ko"
fi
if [[ -n "${MASCARADE_WATCH_FILE}" ]]; then
artifacts+=("${MASCARADE_WATCH_FILE}")
fi
if [[ "${MASCARADE_WATCH_STATUS}" == "degraded" || "${MASCARADE_WATCH_STATUS}" == "failed" || "${MASCARADE_WATCH_STATUS}" == "blocked" ]]; then
degraded_reasons+=("mascarade-watch-${MASCARADE_WATCH_STATUS}")
next_steps+=("bash tools/cockpit/render_mascarade_incident_watch.sh --json")
fi
if [[ "${MASCARADE_WATCH_HISTORY_STATUS}" == "degraded" && "${OVERALL_STATUS}" == "ok" ]]; then
OVERALL_STATUS="degraded"
fi
if [[ "${MASCARADE_WATCH_HISTORY_STATUS}" == "blocked" || "${MASCARADE_WATCH_HISTORY_STATUS}" == "failed" ]]; then
OVERALL_STATUS="ko"
fi
if [[ -n "${MASCARADE_WATCH_HISTORY_FILE}" ]]; then
artifacts+=("${MASCARADE_WATCH_HISTORY_FILE}")
fi
if [[ "${MASCARADE_WATCH_HISTORY_STATUS}" == "degraded" || "${MASCARADE_WATCH_HISTORY_STATUS}" == "failed" || "${MASCARADE_WATCH_HISTORY_STATUS}" == "blocked" ]]; then
degraded_reasons+=("mascarade-watch-history-${MASCARADE_WATCH_HISTORY_STATUS}")
next_steps+=("bash tools/cockpit/render_mascarade_watch_history.sh --json")
fi
if [[ "${OVERALL_STATUS}" == "ok" ]]; then
result="ok"
elif [[ "${OVERALL_STATUS}" == "degraded" ]]; then
result="degraded"
else
result="ko"
fi
MASCARADE_ROUTING_FILE="$LOG_DIR/mascarade_dispatch_route_${timestamp}.json"
if bash "$ROOT_DIR/tools/cockpit/mascarade_dispatch_mesh.sh" --action route --profile "kxkm-mesh-syncops" --json >"$MASCARADE_ROUTING_FILE" 2>>"$log_file"; then
MASCARADE_ROUTING_STATUS="$(json_field "${MASCARADE_ROUTING_FILE}" status)"
else
MASCARADE_ROUTING_STATUS="failed"
fi
if [[ -n "${MASCARADE_ROUTING_FILE}" ]]; then
artifacts+=("${MASCARADE_ROUTING_FILE}")
fi
if [[ "${MASCARADE_ROUTING_STATUS}" == "degraded" || "${MASCARADE_ROUTING_STATUS}" == "failed" || "${MASCARADE_ROUTING_STATUS}" == "blocked" ]]; then
degraded_reasons+=("mascarade-routing-${MASCARADE_ROUTING_STATUS}")
next_steps+=("bash tools/cockpit/mascarade_dispatch_mesh.sh --action route --profile kxkm-mesh-syncops --json")
if [[ "${result}" == "ok" ]]; then
result="degraded"
fi
fi
contract_status="$(json_contract_map_status "${result}")"
if [[ "${contract_status}" == "ok" && "${MASCARADE_ROUTING_STATUS}" == "ok" ]]; then
TRUST_LEVEL="verified"
elif [[ "${MASCARADE_ROUTING_STATUS}" == "ok" ]]; then
TRUST_LEVEL="bounded"
else
TRUST_LEVEL="inferred"
fi
RESUME_REF="kill-life:run-alignment-daily:${timestamp}:${MESH_LOAD_PROFILE}"
KILL_LIFE_MEMORY_FILE="$LOG_DIR/kill_life_memory_run_alignment_daily_${timestamp}.json"
memory_next_step="bash tools/cockpit/run_alignment_daily.sh --json --mesh-load-profile ${MESH_LOAD_PROFILE}"
if [[ ${#next_steps[@]} -gt 0 ]]; then
memory_next_step="${next_steps[0]}"
fi
if bash "$ROOT_DIR/tools/cockpit/write_kill_life_memory_entry.sh" \
--component "run_alignment_daily" \
--status "${contract_status}" \
--owner "SyncOps" \
--decision-action "run-alignment-daily" \
--decision-reason "Daily cockpit alignment is persisted in kill_life with explicit Tower/KXKM routing for continuity." \
--next-step "${memory_next_step}" \
--resume-ref "${RESUME_REF}" \
--trust-level "${TRUST_LEVEL}" \
--routing-file "${MASCARADE_ROUTING_FILE}" \
--artifact "${log_file}" \
--artifact "${summary_file}" \
--artifact "${DAILY_OPERATOR_SUMMARY_FILE}" \
--artifact "${MASCARADE_WATCH_FILE}" \
--json >"$KILL_LIFE_MEMORY_FILE" 2>>"$log_file"; then
KILL_LIFE_MEMORY_STATUS="$(json_field "${KILL_LIFE_MEMORY_FILE}" status)"
KILL_LIFE_MEMORY_MARKDOWN="$(json_field "${KILL_LIFE_MEMORY_FILE}" markdown_file)"
else
KILL_LIFE_MEMORY_STATUS="failed"
fi
if [[ -n "${KILL_LIFE_MEMORY_FILE}" ]]; then
artifacts+=("${KILL_LIFE_MEMORY_FILE}")
fi
if [[ "${KILL_LIFE_MEMORY_STATUS}" == "degraded" || "${KILL_LIFE_MEMORY_STATUS}" == "failed" || "${KILL_LIFE_MEMORY_STATUS}" == "blocked" ]]; then
degraded_reasons+=("kill-life-memory-${KILL_LIFE_MEMORY_STATUS}")
next_steps+=("bash tools/cockpit/write_kill_life_memory_entry.sh --component run_alignment_daily --json")
if [[ "${result}" == "ok" ]]; then
result="degraded"
fi
fi
PRODUCT_CONTRACT_HANDOFF_FILE="$LOG_DIR/product_contract_handoff_${timestamp}.json"
if bash "$ROOT_DIR/tools/cockpit/render_product_contract_handoff.sh" --no-refresh --json >"$PRODUCT_CONTRACT_HANDOFF_FILE" 2>>"$log_file"; then
PRODUCT_CONTRACT_HANDOFF_STATUS="$(json_field "${PRODUCT_CONTRACT_HANDOFF_FILE}" status)"
PRODUCT_CONTRACT_HANDOFF_MARKDOWN="$(json_field "${PRODUCT_CONTRACT_HANDOFF_FILE}" markdown_file)"
else
PRODUCT_CONTRACT_HANDOFF_STATUS="failed"
fi
if [[ -n "${PRODUCT_CONTRACT_HANDOFF_FILE}" ]]; then
artifacts+=("${PRODUCT_CONTRACT_HANDOFF_FILE}")
fi
if [[ "${PRODUCT_CONTRACT_HANDOFF_STATUS}" == "degraded" || "${PRODUCT_CONTRACT_HANDOFF_STATUS}" == "failed" || "${PRODUCT_CONTRACT_HANDOFF_STATUS}" == "blocked" ]]; then
degraded_reasons+=("product-contract-handoff-${PRODUCT_CONTRACT_HANDOFF_STATUS}")
next_steps+=("bash tools/cockpit/render_product_contract_handoff.sh --json")
if [[ "${result}" == "ok" ]]; then
result="degraded"
fi
fi
contract_status="$(json_contract_map_status "${result}")"
{
echo "[summary] final_result=$result"
echo "[summary] final_contract_status=$contract_status"
echo "[summary] mascarade_routing_status=$MASCARADE_ROUTING_STATUS"
echo "[summary] mascarade_routing_file=$MASCARADE_ROUTING_FILE"
echo "[summary] kill_life_memory_status=$KILL_LIFE_MEMORY_STATUS"
echo "[summary] kill_life_memory_file=$KILL_LIFE_MEMORY_FILE"
echo "[summary] kill_life_memory_markdown=$KILL_LIFE_MEMORY_MARKDOWN"
echo "[summary] product_contract_handoff_status=$PRODUCT_CONTRACT_HANDOFF_STATUS"
echo "[summary] product_contract_handoff_file=$PRODUCT_CONTRACT_HANDOFF_FILE"
echo "[summary] product_contract_handoff_markdown=$PRODUCT_CONTRACT_HANDOFF_MARKDOWN"
echo "[summary] trust_level=$TRUST_LEVEL"
echo "[summary] resume_ref=$RESUME_REF"
} | tee -a "$log_file"
cp "$log_file" "$summary_file"
if $json_output; then
decision_json="$(json_decision_object "run-alignment-daily" "Daily cockpit alignment is persisted in kill_life with explicit Tower/KXKM routing for continuity.")"
routing_json="$(json_inline_object "${MASCARADE_ROUTING_FILE}")"
memory_entry_json="$(json_inline_object "${KILL_LIFE_MEMORY_FILE}" entry)"
printf '{"contract_version":"cockpit-v1","component":"run_alignment_daily","action":"summary","status":"%s","contract_status":"%s","generated_at":"%s","registry_file":"%s","result":"%s","mesh_status":"%s","mesh_load_profile":"%s","log_file":"%s","latest_log":"%s","log_dir":"%s","artifacts":%s,"degraded_reasons":%s,"next_steps":%s' \
"$contract_status" "$contract_status" "$start_ts" "$REGISTRY_FILE" "$result" "$mesh_status" "$MESH_LOAD_PROFILE" "$log_file" "$summary_file" "$LOG_DIR" "$(json_contract_array_from_args "${artifacts[@]}")" "$(json_contract_array_from_args "${degraded_reasons[@]}")" "$(json_contract_array_from_args "${next_steps[@]}")"
printf ',"owner":"%s","trust_level":"%s","resume_ref":"%s","routing_status":"%s","routing_artifact":"%s","memory_entry_status":"%s","memory_entry_artifact":"%s","decision":%s,"routing":%s,"memory_entry":%s' \
"SyncOps" "$TRUST_LEVEL" "$RESUME_REF" "$MASCARADE_ROUTING_STATUS" "$MASCARADE_ROUTING_FILE" "$KILL_LIFE_MEMORY_STATUS" "$KILL_LIFE_MEMORY_FILE" "$decision_json" "$routing_json" "$memory_entry_json"
printf ',"registry_summary_file":"%s","registry_summary_status":"%s","registry_target_count":"%s","registry_default_profile":"%s"' \
"$REGISTRY_SUMMARY_FILE" "$REGISTRY_SUMMARY_STATUS" "$REGISTRY_TARGET_COUNT" "$REGISTRY_DEFAULT_PROFILE"
printf ',"mascarade_health_status":"%s","mascarade_runtime_status":"%s","mascarade_provider":"%s","mascarade_model":"%s"' \
"$MASCARADE_HEALTH_STATUS" "$MASCARADE_RUNTIME_STATUS" "$MASCARADE_PROVIDER" "$MASCARADE_MODEL"
if [[ -n "$MASCARADE_HEALTH_FILE" ]]; then
printf ',"mascarade_health_artifact":"%s"' "$MASCARADE_HEALTH_FILE"
fi
if [[ -n "$MASCARADE_LOGS_FILE" ]]; then
printf ',"mascarade_logs_status":"%s","mascarade_logs_artifact":"%s"' "$MASCARADE_LOGS_STATUS" "$MASCARADE_LOGS_FILE"
fi
if [[ -n "$MASCARADE_BRIEF_FILE" ]]; then
printf ',"mascarade_brief_status":"%s","mascarade_brief_artifact":"%s","mascarade_brief_markdown":"%s"' "$MASCARADE_BRIEF_STATUS" "$MASCARADE_BRIEF_FILE" "$MASCARADE_BRIEF_MARKDOWN"
fi
if [[ -n "$MASCARADE_REGISTRY_FILE" ]]; then
printf ',"mascarade_registry_status":"%s","mascarade_registry_artifact":"%s","mascarade_registry_markdown":"%s"' "$MASCARADE_REGISTRY_STATUS" "$MASCARADE_REGISTRY_FILE" "$MASCARADE_REGISTRY_MARKDOWN"
fi
if [[ -n "$MASCARADE_QUEUE_FILE" ]]; then
printf ',"mascarade_queue_status":"%s","mascarade_queue_artifact":"%s","mascarade_queue_markdown":"%s"' "$MASCARADE_QUEUE_STATUS" "$MASCARADE_QUEUE_FILE" "$MASCARADE_QUEUE_MARKDOWN"
fi
if [[ -n "$MASCARADE_WATCH_FILE" ]]; then
printf ',"mascarade_watch_status":"%s","mascarade_watch_artifact":"%s","mascarade_watch_markdown":"%s"' "$MASCARADE_WATCH_STATUS" "$MASCARADE_WATCH_FILE" "$MASCARADE_WATCH_MARKDOWN"
fi
if [[ -n "$MASCARADE_WATCH_HISTORY_FILE" ]]; then
printf ',"mascarade_watch_history_status":"%s","mascarade_watch_history_artifact":"%s","mascarade_watch_history_markdown":"%s"' "$MASCARADE_WATCH_HISTORY_STATUS" "$MASCARADE_WATCH_HISTORY_FILE" "$MASCARADE_WATCH_HISTORY_MARKDOWN"
fi
if [[ -n "$DAILY_OPERATOR_SUMMARY_FILE" ]]; then
printf ',"daily_operator_summary_status":"%s","daily_operator_summary_artifact":"%s","daily_operator_summary_markdown":"%s"' "$DAILY_OPERATOR_SUMMARY_STATUS" "$DAILY_OPERATOR_SUMMARY_FILE" "$DAILY_OPERATOR_SUMMARY_MARKDOWN"
fi
if [[ -n "$KILL_LIFE_MEMORY_MARKDOWN" ]]; then
printf ',"memory_entry_markdown":"%s"' "$KILL_LIFE_MEMORY_MARKDOWN"
fi
if [[ -n "$PRODUCT_CONTRACT_HANDOFF_FILE" ]]; then
printf ',"product_contract_handoff_status":"%s","product_contract_handoff_artifact":"%s","product_contract_handoff_markdown":"%s"' "$PRODUCT_CONTRACT_HANDOFF_STATUS" "$PRODUCT_CONTRACT_HANDOFF_FILE" "$PRODUCT_CONTRACT_HANDOFF_MARKDOWN"
fi
if [[ -n "$mesh_json_file" ]]; then
printf ',"mesh_json_file":"%s"' "$mesh_json_file"
fi
if [[ -n "$LOG_OPS_SUMMARY_FILE" ]]; then
printf ',"log_ops_summary_file":"%s","log_ops_summary_status":"%s","log_ops_stale":"%s"' \
"$LOG_OPS_SUMMARY_FILE" "$LOG_OPS_SUMMARY_STATUS" "$LOG_OPS_STALE"
fi
if [[ -n "$LOG_OPS_PURGE_FILE" ]]; then
printf ',"log_ops_purge_file":"%s","log_ops_purge_status":"%s","log_ops_purged":"%s"' \
"$LOG_OPS_PURGE_FILE" "$LOG_OPS_PURGE_STATUS" "$LOG_OPS_PURGED"
fi
printf '}\n'
fi
if [[ "$result" == ko ]]; then
echo "[error] alignment run failed; consult log: $log_file" >&2
exit 1
fi
+850
View File
@@ -0,0 +1,850 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "${ROOT_DIR}/tools/cockpit/json_contract.sh"
COMPONENT="runtime-mcp-ia-gateway"
ARTIFACT_DIR="${ROOT_DIR}/artifacts/cockpit/runtime_ai_gateway"
mkdir -p "${ARTIFACT_DIR}"
STAMP="$(date +%Y%m%d-%H%M%S)"
RUN_LOG="${ARTIFACT_DIR}/runtime_ai_gateway-${STAMP}.log"
ACTION="status"
JSON=0
REFRESH=0
LOAD_PROFILE="tower-first"
INTELLIGENCE_REPORT="${ROOT_DIR}/artifacts/cockpit/intelligence_program/latest.json"
MASCARADE_REPORT="${ROOT_DIR}/artifacts/ops/mascarade_runtime_health/latest.json"
MESH_REPORT=""
INTELLIGENCE_SCRIPT="${RUNTIME_GATEWAY_INTELLIGENCE_SCRIPT:-${ROOT_DIR}/tools/cockpit/intelligence_tui.sh}"
MESH_SCRIPT="${RUNTIME_GATEWAY_MESH_SCRIPT:-${ROOT_DIR}/tools/cockpit/mesh_health_check.sh}"
MASCARADE_SCRIPT="${RUNTIME_GATEWAY_MASCARADE_SCRIPT:-${ROOT_DIR}/tools/cockpit/mascarade_runtime_health.sh}"
INTELLIGENCE_TIMEOUT_SEC="${RUNTIME_GATEWAY_INTELLIGENCE_TIMEOUT_SEC:-15}"
MESH_TIMEOUT_SEC="${RUNTIME_GATEWAY_MESH_TIMEOUT_SEC:-15}"
MASCARADE_TIMEOUT_SEC="${RUNTIME_GATEWAY_MASCARADE_TIMEOUT_SEC:-15}"
usage() {
cat <<'EOF'
Usage: bash tools/cockpit/runtime_ai_gateway.sh [options]
Options:
--action <status|sources>
--json
--refresh
--load-profile <tower-first|photon-safe>
--intelligence-report <path>
--mesh-report <path>
--mascarade-report <path>
-h, --help
EOF
}
log_line() {
local level="$1"
shift
printf '[%s] [%s] %s\n' "$(date '+%H:%M:%S')" "${level}" "$*" | tee -a "${RUN_LOG}" >&2
}
latest_mesh_report() {
find "${ROOT_DIR}/artifacts/cockpit/health_reports" -type f -name 'mesh_health_check_mesh_*.json' 2>/dev/null | sort | tail -n 1
}
run_refresh_probe() {
local label="$1"
local timeout_sec="$2"
local output_file="$3"
local load_profile="$4"
shift 4
python3 - "${label}" "${timeout_sec}" "${output_file}" "${RUN_LOG}" "${load_profile}" "$@" <<'PY'
from __future__ import annotations
import json
import subprocess
import sys
from datetime import datetime
from pathlib import Path
label = sys.argv[1]
timeout_sec = float(sys.argv[2])
output_file = Path(sys.argv[3])
run_log = Path(sys.argv[4])
load_profile = sys.argv[5]
command = sys.argv[6:]
def now_iso() -> str:
return datetime.now().astimezone().isoformat()
def log(message: str) -> None:
run_log.parent.mkdir(parents=True, exist_ok=True)
with run_log.open("a", encoding="utf-8") as handle:
handle.write(message.rstrip() + "\n")
def fallback(reason: str, detail: str) -> dict[str, object]:
payload: dict[str, object] = {
"generated_at": now_iso(),
"status": "degraded",
"contract_status": "degraded",
"refresh_timeout": reason == "refresh-timeout",
"degraded_reasons": [f"{label}-{reason}"],
"next_steps": [detail],
}
if label == "intelligence":
payload.update(
{
"contract_version": "summary-short/v1",
"summary_short": "Intelligence refresh degraded during gateway refresh.",
"open_task_count": 0,
}
)
elif label == "mesh":
payload.update(
{
"mesh_status": "degraded",
"load_profile": load_profile,
"host_order": [],
}
)
elif label == "mascarade":
payload.update(
{
"provider": "unknown",
"model": "unknown",
"host": "unknown",
}
)
return payload
def write_payload(payload: dict[str, object]) -> None:
output_file.parent.mkdir(parents=True, exist_ok=True)
output_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout_sec, check=False)
if result.stdout.strip():
output_file.write_text(result.stdout, encoding="utf-8")
else:
write_payload(
fallback(
"refresh-empty",
f"Refresh {label} produced no JSON payload; inspect the source command.",
)
)
if result.stderr.strip():
log(f"[refresh:{label}:stderr]\n{result.stderr.rstrip()}")
log(f"[refresh:{label}] returncode={result.returncode} timeout={timeout_sec}s")
if result.returncode != 0:
write_payload(
fallback(
"refresh-failed",
f"Refresh {label} failed; inspect the source command and its artifacts.",
)
)
except subprocess.TimeoutExpired as exc:
stdout = exc.stdout or ""
stderr = exc.stderr or ""
if stdout.strip():
log(f"[refresh:{label}:partial-stdout]\n{stdout.rstrip()}")
if stderr.strip():
log(f"[refresh:{label}:partial-stderr]\n{stderr.rstrip()}")
log(f"[refresh:{label}] timeout after {timeout_sec}s")
write_payload(
fallback(
"refresh-timeout",
f"Refresh {label} timed out after {timeout_sec}s; reduce probe cost or inspect the upstream source.",
)
)
PY
}
refresh_sources() {
local intelligence_out="${ARTIFACT_DIR}/intelligence-${STAMP}.json"
local mesh_out="${ARTIFACT_DIR}/mesh-${STAMP}.json"
local mascarade_out="${ARTIFACT_DIR}/mascarade-${STAMP}.json"
log_line "INFO" "refreshing intelligence memory"
run_refresh_probe intelligence "${INTELLIGENCE_TIMEOUT_SEC}" "${intelligence_out}" "${LOAD_PROFILE}" \
bash "${INTELLIGENCE_SCRIPT}" --action memory --json
INTELLIGENCE_REPORT="${intelligence_out}"
log_line "INFO" "refreshing mesh health"
run_refresh_probe mesh "${MESH_TIMEOUT_SEC}" "${mesh_out}" "${LOAD_PROFILE}" \
bash "${MESH_SCRIPT}" --json --load-profile "${LOAD_PROFILE}"
MESH_REPORT="${mesh_out}"
log_line "INFO" "refreshing Mascarade runtime health"
run_refresh_probe mascarade "${MASCARADE_TIMEOUT_SEC}" "${mascarade_out}" "${LOAD_PROFILE}" \
bash "${MASCARADE_SCRIPT}" --json
MASCARADE_REPORT="${mascarade_out}"
}
emit_status_json() {
python3 - "${ROOT_DIR}" "${INTELLIGENCE_REPORT}" "${MESH_REPORT}" "${MASCARADE_REPORT}" "${RUN_LOG}" <<'PY'
from __future__ import annotations
import json
import sys
from datetime import datetime
from pathlib import Path
root = Path(sys.argv[1])
intelligence_path = Path(sys.argv[2]) if sys.argv[2] else None
mesh_path = Path(sys.argv[3]) if sys.argv[3] else None
mascarade_path = Path(sys.argv[4]) if sys.argv[4] else None
run_log = Path(sys.argv[5])
def read_json(path: Path | None) -> dict[str, object] | None:
if path is None or not path.exists():
return None
text = path.read_text(encoding="utf-8")
try:
return json.loads(text)
except Exception:
decoder = json.JSONDecoder()
lines = text.splitlines()
for index, line in enumerate(lines):
if line.lstrip().startswith("{"):
try:
candidate = "\n".join(lines[index:])
payload, _ = decoder.raw_decode(candidate)
if isinstance(payload, dict):
return payload
except Exception:
continue
return None
def relative_path(path: Path | None) -> str | None:
if path is None:
return None
try:
return str(path.resolve().relative_to(root))
except Exception:
return str(path)
def normalize(value: object) -> str:
text = str(value or "").strip().lower()
if text in {"ok", "ready", "done", "success"}:
return "ready"
if text in {"degraded", "warn", "warning", "pending", "running", "unknown", "missing"}:
return "degraded"
if text in {"blocked", "error", "fail", "failed", "unreachable", "invalid"}:
return "blocked"
return "degraded"
def collect_next_steps(payload: dict[str, object] | None) -> list[str]:
if not payload:
return []
raw = payload.get("next_steps")
if isinstance(raw, list):
return [str(item) for item in raw if item]
return []
def compact(value: object, max_len: int = 220) -> str:
text = " ".join(str(value or "").split())
if not text:
return "none"
if len(text) <= max_len:
return text
return text[: max_len - 3].rstrip() + "..."
def listify(value: object) -> list[str]:
if isinstance(value, list):
return [str(item) for item in value if item]
return []
def merge_unique(values: list[str], additions: list[str]) -> list[str]:
for item in additions:
if item and item not in values:
values.append(item)
return values
def path_evidence(label: str, path: Path | None) -> list[str]:
rel = relative_path(path)
if rel:
return [rel]
return [f"missing:{label}"]
def build_runtime_surface(payload: dict[str, object] | None, path: Path | None) -> tuple[dict[str, object], list[str]]:
status = normalize((payload or {}).get("status") or (payload or {}).get("runtime_status") or (payload or {}).get("contract_status"))
provider = (payload or {}).get("provider") or (payload or {}).get("mascarade_provider") or "unknown"
model = (payload or {}).get("model") or (payload or {}).get("mascarade_model") or "unknown"
host = (payload or {}).get("host") or "unknown"
degraded = listify((payload or {}).get("degraded_reasons"))
if status != "ready" and not degraded:
degraded = [f"runtime-{status}"]
next_steps = collect_next_steps(payload)
if status != "ready" and not next_steps:
next_steps = ["Refresh Mascarade runtime health and inspect provider/model availability."]
surface = {
"status": status,
"summary_short": compact(
f"Mascarade runtime {status}; provider={provider}; model={model}; host={host}.",
220,
),
"evidence": path_evidence("runtime", path),
"degraded_reasons": degraded,
"upstreams": ["tools/cockpit/mascarade_runtime_health.sh"],
"provider": provider,
"model": model,
"host": host,
"path": relative_path(path),
}
return surface, next_steps
def build_mcp_surface(payload: dict[str, object] | None, path: Path | None) -> tuple[dict[str, object], list[str]]:
host_order_raw = (payload or {}).get("host_order") or (payload or {}).get("mesh_host_order") or []
if isinstance(host_order_raw, str):
try:
host_order = json.loads(host_order_raw)
except Exception:
host_order = [item.strip() for item in host_order_raw.split(",") if item.strip()]
else:
host_order = [str(item) for item in host_order_raw if item]
load_profile = (payload or {}).get("load_profile") or "unknown"
registry_status_raw = (payload or {}).get("registry_status")
registry_status = normalize(registry_status_raw) if registry_status_raw else "unknown"
status = normalize((payload or {}).get("status") or (payload or {}).get("mesh_status") or registry_status_raw)
degraded = listify((payload or {}).get("degraded_reasons"))
if (payload or {}).get("mesh_status") and normalize((payload or {}).get("mesh_status")) != "ready":
degraded = merge_unique(degraded, [f"mesh-{normalize((payload or {}).get('mesh_status'))}"])
if status != "ready" and not degraded:
degraded = [f"mcp-{status}"]
next_steps = collect_next_steps(payload)
if status != "ready" and not next_steps:
next_steps = ["Refresh mesh health and inspect machine registry / host order."]
surface = {
"status": status,
"summary_short": compact(
f"Mesh/MCP {status}; load_profile={load_profile}; host_order={len(host_order)}; registry={registry_status}.",
220,
),
"evidence": path_evidence("mcp", path),
"degraded_reasons": degraded,
"upstreams": ["tools/cockpit/mesh_health_check.sh", "specs/contracts/machine_registry.mesh.json"],
"load_profile": load_profile,
"host_order": host_order,
"path": relative_path(path),
}
return surface, next_steps
def build_ia_surface(payload: dict[str, object] | None, path: Path | None) -> tuple[dict[str, object], list[str]]:
status = normalize((payload or {}).get("status"))
degraded = listify((payload or {}).get("degraded_reasons"))
if status != "ready" and not degraded:
degraded = [f"ia-{status}"]
next_steps = collect_next_steps(payload)
if status != "ready" and not next_steps:
next_steps = ["Refresh intelligence memory and update the open governance tasks."]
summary = (payload or {}).get("summary_short")
if not summary:
summary = compact(
f"Intelligence lane {status}; open_tasks={(payload or {}).get('open_task_count', 0)}; "
f"next={(next_steps[0] if next_steps else 'none')}.",
220,
)
evidence = listify((payload or {}).get("evidence"))
if evidence:
evidence = [str(item) for item in evidence]
else:
evidence = path_evidence("ia", path)
surface = {
"status": status,
"summary_short": compact(summary, 220),
"evidence": evidence,
"degraded_reasons": degraded,
"upstreams": ["tools/cockpit/intelligence_tui.sh"],
"open_task_count": (payload or {}).get("open_task_count", 0),
"path": relative_path(path),
}
return surface, next_steps
def build_firmware_cad_surface(root: Path) -> tuple[dict[str, object], dict[str, object], list[str]]:
firmware_main = root / "firmware" / "src" / "main.cpp"
firmware_platformio = root / "firmware" / "platformio.ini"
firmware_voice = root / "firmware" / "src" / "voice_controller.cpp"
firmware_build_dir = root / "firmware" / ".pio" / "build" / "esp32s3_arduino"
firmware_bin = firmware_build_dir / "firmware.bin"
firmware_elf = firmware_build_dir / "firmware.elf"
firmware_build_result = root / "docs" / "evidence" / "esp" / "build.result.json"
firmware_build_stderr = root / "docs" / "evidence" / "esp" / "build.stderr.txt"
firmware_test_result = root / "docs" / "evidence" / "linux" / "test.result.json"
firmware_test_stderr = root / "docs" / "evidence" / "linux" / "test.stderr.txt"
cad_proof = root / "artifacts" / "yiacad_backend_proof" / "latest.json"
cad_fusion = root / "artifacts" / "cad-fusion" / "yiacad-fusion-last-status.md"
firmware_exists = firmware_main.exists() and firmware_platformio.exists()
firmware_build_ready = firmware_bin.exists() and firmware_elf.exists()
firmware_build_payload = read_json(firmware_build_result)
firmware_test_payload = read_json(firmware_test_result)
firmware_build_rc = (firmware_build_payload or {}).get("returncode")
firmware_test_rc = (firmware_test_payload or {}).get("returncode")
has_failed_run = firmware_build_rc not in (None, 0) or firmware_test_rc not in (None, 0)
if firmware_build_ready:
firmware_status = "ready"
elif firmware_exists and has_failed_run:
firmware_status = "degraded"
elif firmware_exists:
firmware_status = "degraded"
else:
firmware_status = "blocked"
firmware_reasons: list[str] = []
firmware_next: list[str] = []
if not firmware_exists:
firmware_reasons.append("firmware-root-missing")
firmware_next.append("Restore the canonical firmware root files before publishing firmware status.")
elif has_failed_run:
if firmware_build_rc not in (None, 0):
firmware_reasons.append("firmware-build-last-run-failed")
if firmware_test_rc not in (None, 0):
firmware_reasons.append("firmware-test-last-run-failed")
firmware_next.append("Resolve the current PlatformIO dependency/network error, then rerun `python3 tools/build_firmware.py esp`.")
firmware_next.append("Rerun `python3 tools/test_firmware.py linux` after the PlatformIO dependency issue is cleared.")
elif not firmware_build_ready:
firmware_reasons.append("firmware-build-evidence-missing")
firmware_next.append("Run `python3 tools/build_firmware.py esp` to publish root firmware build evidence.")
firmware_next.append("Run `python3 tools/test_firmware.py linux` when native test evidence is needed.")
cad_payload = read_json(cad_proof)
cad_status_raw = (cad_payload or {}).get("status")
if cad_status_raw in {"done", "ok", "ready"}:
cad_status = "ready"
elif cad_status_raw in {"degraded", "warn", "warning"}:
cad_status = "degraded"
elif cad_status_raw in {"blocked", "error", "fail", "failed"}:
cad_status = "blocked"
else:
cad_status = "degraded"
cad_reasons = listify((cad_payload or {}).get("degraded_reasons"))
cad_next = collect_next_steps(cad_payload)
if not cad_payload:
cad_reasons = merge_unique(cad_reasons, ["cad-proof-missing"])
cad_next = merge_unique(cad_next, ["Run `bash tools/cockpit/yiacad_backend_proof.sh --action run --json` to publish CAD operator proof."])
combined_statuses = [firmware_status, cad_status]
if any(item == "blocked" for item in combined_statuses):
overall = "blocked"
elif any(item == "degraded" for item in combined_statuses):
overall = "degraded"
else:
overall = "ready"
evidence: list[str] = []
for candidate in (
firmware_main,
firmware_platformio,
firmware_bin,
firmware_elf,
firmware_build_result,
firmware_build_stderr,
firmware_test_result,
firmware_test_stderr,
firmware_voice,
cad_proof,
cad_fusion,
):
rel = relative_path(candidate) if candidate.exists() else None
if rel and rel not in evidence:
evidence.append(rel)
next_steps = firmware_next[:]
for item in cad_next:
if item not in next_steps:
next_steps.append(item)
surface = {
"status": overall,
"summary_short": compact(
f"firmware={firmware_status} main={'yes' if firmware_main.exists() else 'no'} build={'yes' if firmware_build_ready else 'no'} "
f"build_rc={firmware_build_rc if firmware_build_rc is not None else 'n/a'} test_rc={firmware_test_rc if firmware_test_rc is not None else 'n/a'}; "
f"cad={cad_status} proof={'yes' if cad_payload else 'no'}.",
220,
),
"evidence": evidence or ["missing:firmware-cad"],
"degraded_reasons": firmware_reasons + [item for item in cad_reasons if item not in firmware_reasons],
"upstreams": [
"firmware/platformio.ini",
"firmware/src/main.cpp",
"tools/build_firmware.py",
"tools/test_firmware.py",
"tools/cockpit/yiacad_backend_proof.sh",
],
"firmware_status": firmware_status,
"cad_status": cad_status,
"firmware_entrypoint": relative_path(firmware_main) if firmware_main.exists() else None,
"firmware_build_dir": relative_path(firmware_build_dir) if firmware_build_dir.exists() else None,
"firmware_build_result": relative_path(firmware_build_result) if firmware_build_result.exists() else None,
"firmware_test_result": relative_path(firmware_test_result) if firmware_test_result.exists() else None,
"cad_proof_path": relative_path(cad_proof) if cad_proof.exists() else None,
}
summary_payload = {
"contract_version": "summary-short/v1",
"generated_at": datetime.now().astimezone().isoformat(),
"component": "firmware-cad-bridge",
"lot_id": "firmware-cad-bridge",
"owner_repo": "Kill_LIFE",
"owner_agent": "Arch-Mesh",
"owner_subagent": "CAD-Bridge",
"write_set": [
"firmware/platformio.ini",
"firmware/src/main.cpp",
"firmware/src/voice_controller.cpp",
"tools/build_firmware.py",
"tools/test_firmware.py",
"tools/cockpit/yiacad_backend_proof.sh",
],
"status": overall,
"summary_short": compact(
f"Firmware/CAD bridge {overall}; firmware={firmware_status}; cad={cad_status}; "
f"next={(next_steps[0] if next_steps else 'none')}.",
320,
),
"evidence": surface["evidence"],
"degraded_reasons": surface["degraded_reasons"],
"next_steps": next_steps[:5],
}
return surface, summary_payload, next_steps
intelligence = read_json(intelligence_path)
mesh = read_json(mesh_path)
mascarade = read_json(mascarade_path)
runtime_surface, runtime_next = build_runtime_surface(mascarade, mascarade_path)
mcp_surface, mcp_next = build_mcp_surface(mesh, mesh_path)
ia_surface, ia_next = build_ia_surface(intelligence, intelligence_path)
firmware_cad_surface, firmware_cad_summary, firmware_cad_next = build_firmware_cad_surface(root)
source_states = [runtime_surface["status"], mcp_surface["status"], ia_surface["status"]]
if any(state == "blocked" for state in source_states):
overall = "blocked"
elif any(state == "degraded" for state in source_states):
overall = "degraded"
else:
overall = "ready"
degraded_reasons: list[str] = []
next_steps: list[str] = []
evidence = [relative_path(run_log) or str(run_log)]
for label, path, surface, extra_next in (
("runtime", mascarade_path, runtime_surface, runtime_next),
("mcp", mesh_path, mcp_surface, mcp_next),
("ia", intelligence_path, ia_surface, ia_next),
):
if path and path.exists():
rel = relative_path(path)
if rel and rel not in evidence:
evidence.append(rel)
else:
degraded_reasons.append(f"{label}-missing")
next_steps.append(f"Provide a {label} report or run the source command.")
for reason in listify(surface.get("degraded_reasons")):
if reason not in degraded_reasons:
degraded_reasons.append(reason)
for step in extra_next:
if step not in next_steps:
next_steps.append(step)
for reason in listify(firmware_cad_surface.get("degraded_reasons")):
if reason not in degraded_reasons:
degraded_reasons.append(reason)
for step in firmware_cad_next:
if step not in next_steps:
next_steps.append(step)
for item in listify(firmware_cad_surface.get("evidence")):
if item not in evidence:
evidence.append(item)
summary_short = compact(
f"runtime={runtime_surface['status']} ({runtime_surface['summary_short']}) | "
f"mcp={mcp_surface['status']} ({mcp_surface['summary_short']}) | "
f"ia={ia_surface['status']} ({ia_surface['summary_short']}) | "
f"firmware_cad={firmware_cad_surface['status']} ({firmware_cad_surface['summary_short']})",
320,
)
payload = {
"contract_version": "runtime-mcp-ia-gateway/v1",
"component": "runtime-mcp-ia-gateway",
"action": "status",
"status": overall,
"generated_at": datetime.now().astimezone().isoformat(),
"owner_repo": "Kill_LIFE",
"owner_agent": "Runtime-Companion",
"owner_subagent": "MCP-Health",
"write_set": [
"tools/cockpit/runtime_ai_gateway.sh",
"tools/cockpit/intelligence_tui.sh",
"tools/cockpit/mesh_health_check.sh",
"tools/cockpit/mascarade_runtime_health.sh",
"docs/AI_WORKFLOWS.md",
],
"summary_short": summary_short,
"evidence": evidence,
"degraded_reasons": degraded_reasons,
"next_steps": next_steps[:5],
"goal": "Publier une sante canonique runtime/MCP/IA exploitable par cockpit, docs et extensions.",
"state": f"runtime={runtime_surface['status']} mcp={mcp_surface['status']} ia={ia_surface['status']}",
"blockers": degraded_reasons,
"next": next_steps[:3],
"owner": "Runtime-Companion/MCP-Health",
"contract_status": "ok" if overall == "ready" else ("error" if overall == "blocked" else "degraded"),
"log_file": str(run_log),
"artifacts": evidence,
"surfaces": {
"runtime": runtime_surface,
"mcp": mcp_surface,
"ia": ia_surface,
"firmware_cad": firmware_cad_surface,
},
"sources": {
"intelligence": {
"path": relative_path(intelligence_path),
"status": ia_surface["status"],
"open_task_count": (intelligence or {}).get("open_task_count", 0),
"refresh_timeout": bool((intelligence or {}).get("refresh_timeout")),
},
"mesh": {
"path": relative_path(mesh_path),
"status": mcp_surface["status"],
"load_profile": (mesh or {}).get("load_profile", "unknown"),
"host_order": mcp_surface.get("host_order", []),
"refresh_timeout": bool((mesh or {}).get("refresh_timeout")),
},
"mascarade": {
"path": relative_path(mascarade_path),
"status": runtime_surface["status"],
"provider": (mascarade or {}).get("provider") or (mascarade or {}).get("mascarade_provider"),
"model": (mascarade or {}).get("model") or (mascarade or {}).get("mascarade_model"),
"refresh_timeout": bool((mascarade or {}).get("refresh_timeout")),
},
"firmware_cad": {
"status": firmware_cad_surface["status"],
"firmware_status": firmware_cad_surface["firmware_status"],
"cad_status": firmware_cad_surface["cad_status"],
"cad_proof_path": firmware_cad_surface["cad_proof_path"],
},
},
"summary_short_artifacts": {
"firmware_cad": {
"json": "artifacts/cockpit/runtime_ai_gateway/firmware_cad_summary_short_latest.json",
"markdown": "artifacts/cockpit/runtime_ai_gateway/firmware_cad_summary_short_latest.md",
}
},
"firmware_cad_summary_short": firmware_cad_summary,
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
PY
}
render_status_text() {
python3 - "${1}" <<'PY'
from __future__ import annotations
import json
import sys
payload = json.loads(sys.argv[1])
print("# Runtime / MCP / IA gateway")
print()
print(f"- status: {payload.get('status')}")
for name, source in (payload.get("surfaces") or {}).items():
print(f"- {name}: {source.get('status')} ({source.get('path') or 'missing'})")
if payload.get("next_steps"):
print("- next_steps:")
for step in payload["next_steps"]:
print(f" - {step}")
PY
}
persist_status_snapshot() {
python3 - "${1}" "${ARTIFACT_DIR}" <<'PY'
from __future__ import annotations
import json
import sys
from pathlib import Path
payload = json.loads(sys.argv[1])
artifact_dir = Path(sys.argv[2])
latest_json = artifact_dir / "latest.json"
latest_md = artifact_dir / "latest.md"
firmware_cad_summary_json = artifact_dir / "firmware_cad_summary_short_latest.json"
firmware_cad_summary_md = artifact_dir / "firmware_cad_summary_short_latest.md"
surface_lines = []
for name, surface in (payload.get("surfaces") or {}).items():
surface_lines.append(
f"- {name}: status={surface.get('status')} summary={surface.get('summary_short')}"
)
md_lines = [
"# Runtime / MCP / IA gateway snapshot",
"",
f"- generated_at: {payload.get('generated_at')}",
f"- status: {payload.get('status')}",
f"- owner: {payload.get('owner')}",
f"- summary_short: {payload.get('summary_short')}",
"",
"## Surfaces",
*surface_lines,
"",
"## Next steps",
]
for step in payload.get("next_steps") or []:
md_lines.append(f"- {step}")
if not (payload.get("next_steps") or []):
md_lines.append("- none")
latest_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
latest_md.write_text("\n".join(md_lines) + "\n", encoding="utf-8")
firmware_cad_summary = payload.get("firmware_cad_summary_short")
if isinstance(firmware_cad_summary, dict):
firmware_cad_summary_json.write_text(
json.dumps(firmware_cad_summary, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
summary_md_lines = [
"# Firmware / CAD bridge summary",
"",
f"- generated_at: {firmware_cad_summary.get('generated_at')}",
f"- status: {firmware_cad_summary.get('status')}",
f"- owner: {firmware_cad_summary.get('owner_agent')}/{firmware_cad_summary.get('owner_subagent')}",
f"- summary_short: {firmware_cad_summary.get('summary_short')}",
"",
"## Evidence",
]
for item in firmware_cad_summary.get("evidence") or []:
summary_md_lines.append(f"- {item}")
if not (firmware_cad_summary.get("evidence") or []):
summary_md_lines.append("- none")
summary_md_lines.extend(["", "## Next steps"])
for item in firmware_cad_summary.get("next_steps") or []:
summary_md_lines.append(f"- {item}")
if not (firmware_cad_summary.get("next_steps") or []):
summary_md_lines.append("- none")
firmware_cad_summary_md.write_text("\n".join(summary_md_lines) + "\n", encoding="utf-8")
PY
}
while [ "$#" -gt 0 ]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--json)
JSON=1
shift
;;
--refresh)
REFRESH=1
shift
;;
--load-profile)
LOAD_PROFILE="${2:-tower-first}"
shift 2
;;
--intelligence-report)
INTELLIGENCE_REPORT="${2:-}"
shift 2
;;
--mesh-report)
MESH_REPORT="${2:-}"
shift 2
;;
--mascarade-report)
MASCARADE_REPORT="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
if [ -z "${MESH_REPORT}" ]; then
MESH_REPORT="$(latest_mesh_report || true)"
fi
if [ "${REFRESH}" -eq 1 ]; then
refresh_sources
fi
case "${ACTION}" in
status)
log_line "INFO" "action=status refresh=${REFRESH}"
STATUS_JSON="$(emit_status_json)"
persist_status_snapshot "${STATUS_JSON}"
if [ "${JSON}" -eq 1 ]; then
printf '%s\n' "${STATUS_JSON}"
else
render_status_text "${STATUS_JSON}"
fi
;;
sources)
log_line "INFO" "action=sources"
if [ "${JSON}" -eq 1 ]; then
printf '{\n'
printf ' "contract_version": "cockpit-v1",\n'
printf ' "component": "%s",\n' "${COMPONENT}"
printf ' "action": "sources",\n'
printf ' "status": "done",\n'
printf ' "contract_status": "ok",\n'
printf ' "log_file": "%s",\n' "${RUN_LOG}"
printf ' "artifacts": %s,\n' "$(json_contract_array_from_args "${RUN_LOG}" "${INTELLIGENCE_REPORT}" "${MESH_REPORT}" "${MASCARADE_REPORT}")"
printf ' "degraded_reasons": [],\n'
printf ' "next_steps": [],\n'
printf ' "intelligence_report": "%s",\n' "${INTELLIGENCE_REPORT}"
if [ -n "${MESH_REPORT}" ]; then
printf ' "mesh_report": "%s",\n' "${MESH_REPORT}"
else
printf ' "mesh_report": null,\n'
fi
printf ' "mascarade_report": "%s"\n' "${MASCARADE_REPORT}"
printf '}\n'
else
printf '# Runtime / MCP / IA sources\n\n'
printf -- '- intelligence: %s\n' "${INTELLIGENCE_REPORT}"
printf -- '- mesh: %s\n' "${MESH_REPORT:-missing}"
printf -- '- mascarade: %s\n' "${MASCARADE_REPORT}"
fi
;;
*)
printf 'Unsupported action: %s\n' "${ACTION}" >&2
usage >&2
exit 2
;;
esac
+370
View File
@@ -0,0 +1,370 @@
#!/bin/bash
# ============================================================================
# sentinelle_cron.sh — Cron daily health-check via Sentinelle agent
# Contrat: cockpit-v1
# Lot: 23 — T-MA-022
# Date: 2026-03-21
#
# Usage: Ajouter au crontab:
# 0 6 * * * /path/to/sentinelle_cron.sh >> /var/log/sentinelle-health.log 2>&1
#
# Ou via n8n: HTTP Request node → POST /webhook/sentinelle-health
# ============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
# shellcheck source=/dev/null
source "${ROOT_DIR}/tools/cockpit/load_mistral_governance_env.sh"
LOG_DIR="${LOG_DIR:-/var/log/mascarade}"
REPORT_FILE="${LOG_DIR}/sentinelle-health-$(date +%Y%m%d).json"
MISTRAL_API_KEY="${MISTRAL_GOVERNANCE_API_KEY:-${MISTRAL_API_KEY:-}}"
SENTINELLE_AGENT_ID="${MISTRAL_AGENT_SENTINELLE_ID:-ag_019d124c302375a8bf06f9ff8a99fb5f}"
MISTRAL_BASE="https://api.mistral.ai/v1"
API_MODE="${MISTRAL_AGENTS_API_MODE:-beta}"
SENTINELLE_ANALYSIS_API_MODE="not-requested"
# Services à checker
MASCARADE_URL="${MASCARADE_URL:-https://mascarade.saillant.cc}"
LANGFUSE_URL="${LANGFUSE_URL:-https://langfuse.saillant.cc}"
GRAFANA_URL="${GRAFANA_URL:-https://grafana.saillant.cc}"
# Alerting
ALERT_WEBHOOK="${ALERT_WEBHOOK:-}" # n8n ou Slack webhook
ALERT_EMAIL="${ALERT_EMAIL:-c.saillant@gmail.com}"
# --- Fonctions ---
mkdir -p "$LOG_DIR" 2>/dev/null || true
timestamp() {
date -u +%Y-%m-%dT%H:%M:%SZ
}
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
check_url() {
local name="$1" url="$2"
local http_code
http_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -L "$url" 2>/dev/null || echo "000")
echo "{\"service\":\"$name\",\"url\":\"$url\",\"http_code\":$http_code,\"healthy\":$([ "$http_code" -ge 200 ] && [ "$http_code" -lt 500 ] && echo "true" || echo "false")}"
}
build_beta_payload() {
local agent_id="$1"
local message="$2"
python3 - "$agent_id" "$message" <<'PY'
import json
import sys
agent_id = sys.argv[1]
message = sys.argv[2]
print(json.dumps({
"agent_id": agent_id,
"inputs": [{"role": "user", "content": message}],
}))
PY
}
build_deprecated_payload() {
local message="$1"
python3 - "$message" <<'PY'
import json
import sys
message = sys.argv[1]
print(json.dumps({
"messages": [{"role": "user", "content": message}],
"max_tokens": 500,
}))
PY
}
call_mistral_api() {
local endpoint="$1"
local payload="$2"
local response
response=$(curl -s -w "\n%{http_code}" \
-H "Authorization: Bearer $MISTRAL_API_KEY" \
-H "Content-Type: application/json" \
-d "$payload" \
--max-time 60 \
"${MISTRAL_BASE}/${endpoint}" 2>/dev/null) || true
local http_code body
http_code=$(printf '%s\n' "$response" | tail -1)
body=$(printf '%s\n' "$response" | sed '$d')
if [ "$http_code" = "200" ]; then
printf '%s\n' "$body"
return 0
fi
printf 'HTTP_ERROR:%s:%s\n' "$http_code" "$body"
return 1
}
extract_agent_content() {
local response="$1"
python3 - "$response" <<'PY' 2>/dev/null || echo ""
import json
import sys
raw = sys.argv[1]
def render_content(content):
if isinstance(content, str):
return content
if isinstance(content, list):
chunks = []
for item in content:
if isinstance(item, str):
if item:
chunks.append(item)
continue
if not isinstance(item, dict):
continue
text = item.get("text")
if isinstance(text, str) and text:
chunks.append(text)
return "\n".join(chunks)
return ""
try:
data = json.loads(raw)
outputs = data.get("outputs", [])
for out in outputs:
if out.get("role") == "assistant":
print(render_content(out.get("content", "")))
raise SystemExit(0)
choices = data.get("choices", [])
if choices:
print(render_content(choices[0].get("message", {}).get("content", "")))
else:
print(data.get("error", "no response"))
except Exception:
print("")
PY
}
# --- Health Checks ---
run_health_checks() {
log "Starting daily health check..."
local checks=()
# Core services
checks+=("$(check_url "mascarade" "$MASCARADE_URL/health")")
checks+=("$(check_url "langfuse" "$LANGFUSE_URL")")
checks+=("$(check_url "grafana" "$GRAFANA_URL")")
checks+=("$(check_url "authentik" "https://auth.saillant.cc")")
checks+=("$(check_url "outline" "https://wiki.saillant.cc")")
checks+=("$(check_url "n8n" "https://n8n.saillant.cc")")
checks+=("$(check_url "gitea" "https://git.saillant.cc")")
checks+=("$(check_url "uptime-kuma" "https://uptime.saillant.cc")")
checks+=("$(check_url "portainer" "https://portainer.saillant.cc")")
# Count healthy
local total=${#checks[@]}
local healthy=0
for c in "${checks[@]}"; do
if echo "$c" | grep -q '"healthy":true'; then
((healthy++))
fi
done
local status="healthy"
[ "$healthy" -lt "$total" ] && status="degraded"
[ "$healthy" -lt $((total / 2)) ] && status="critical"
# Build JSON array
local checks_json=""
for i in "${!checks[@]}"; do
[ "$i" -gt 0 ] && checks_json="${checks_json},"
checks_json="${checks_json}${checks[$i]}"
done
# Generate report
cat <<EOF > "$REPORT_FILE"
{
"contract_version": "cockpit-v1",
"component": "sentinelle-daily-health",
"timestamp": "$(timestamp)",
"status": "$status",
"services_healthy": $healthy,
"services_total": $total,
"uptime_pct": $(( healthy * 100 / total )),
"checks": [$checks_json]
}
EOF
log "Health check complete: $healthy/$total healthy ($status)"
echo "$status"
}
# --- Sentinelle Agent Analysis ---
ask_sentinelle() {
local report="$1"
if [ -z "$MISTRAL_API_KEY" ]; then
log "MISTRAL_API_KEY not set — skipping Sentinelle analysis"
return
fi
log "Sending report to Sentinelle agent for analysis..."
local report_content
report_content=$(cat "$report")
local message
message="Daily health check report for saillant.cc ecosystem.
Analyze this report and provide:
1. A brief status summary (1-2 lines)
2. Any issues that need immediate attention
3. Recommendations for the next 24h
4. Risk level: LOW/MEDIUM/HIGH/CRITICAL
Report:
${report_content}"
local beta_payload
local deprecated_payload
local response=""
local analysis=""
local api_mode="failed"
beta_payload="$(build_beta_payload "$SENTINELLE_AGENT_ID" "$message")"
deprecated_payload="$(build_deprecated_payload "$message")"
if [ "$API_MODE" = "beta" ]; then
if response=$(call_mistral_api "conversations" "$beta_payload" 2>/dev/null); then
api_mode="beta"
else
log "Beta API failed for Sentinelle — fallback deprecated"
fi
fi
if [ "$api_mode" = "failed" ]; then
if response=$(call_mistral_api "agents/${SENTINELLE_AGENT_ID}/completions" "$deprecated_payload" 2>/dev/null); then
api_mode="deprecated"
else
response='{"error":"timeout"}'
fi
fi
if [[ "$response" == HTTP_ERROR:* ]]; then
analysis="$response"
else
analysis=$(extract_agent_content "$response")
fi
if [ -z "$analysis" ]; then
analysis="no analysis"
fi
SENTINELLE_ANALYSIS_API_MODE="$api_mode"
# Append analysis to report
python3 - "$report" "$analysis" "$api_mode" <<'PY' 2>/dev/null || true
import json
import sys
report_path = sys.argv[1]
analysis = sys.argv[2]
api_mode = sys.argv[3]
with open(report_path, "r", encoding="utf-8") as handle:
data = json.load(handle)
data["sentinelle_analysis"] = analysis
data["sentinelle_api_mode"] = api_mode
with open(report_path, "w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2)
PY
log "Sentinelle analysis appended to report"
}
# --- Alerting ---
send_alert() {
local status="$1" report="$2"
# Only alert on degraded or critical
if [ "$status" = "healthy" ]; then
return
fi
log "Sending alert for status: $status"
# Webhook alert (n8n, Slack, etc.)
if [ -n "$ALERT_WEBHOOK" ]; then
curl -s -X POST "$ALERT_WEBHOOK" \
-H "Content-Type: application/json" \
-d @"$report" \
--max-time 10 2>/dev/null || log "Webhook alert failed"
fi
# Console output for cron email
echo ""
echo "=== SENTINELLE ALERT: $status ==="
echo "Date: $(date)"
echo "Report: $report"
cat "$report"
echo "================================="
}
# --- Main ---
main() {
local status
status=$(run_health_checks)
ask_sentinelle "$REPORT_FILE"
send_alert "$status" "$REPORT_FILE"
# Cleanup old reports (keep 30 days)
find "$LOG_DIR" -name "sentinelle-health-*.json" -mtime +30 -delete 2>/dev/null || true
log "Daily health check finished (status: $status, report: $REPORT_FILE)"
# Output JSON for cockpit-v1
cat <<EOF
{
"contract_version": "cockpit-v1",
"component": "sentinelle-cron",
"action": "daily-health",
"status": "$status",
"timestamp": "$(timestamp)",
"report_file": "$REPORT_FILE",
"analysis_api_mode": "$SENTINELLE_ANALYSIS_API_MODE"
}
EOF
}
case "${1:-}" in
--help|-h)
echo "Usage: $0 [--help]"
echo ""
echo "Daily health check via Sentinelle agent."
echo ""
echo "Env vars:"
echo " MISTRAL_API_KEY Mistral API key"
echo " MISTRAL_AGENT_SENTINELLE_ID Sentinelle agent ID"
echo " LOG_DIR Log directory (default: /var/log/mascarade)"
echo " ALERT_WEBHOOK Webhook URL for alerts"
echo " ALERT_EMAIL Email for alerts"
echo ""
echo "Crontab example:"
echo " 0 6 * * * $0 >> /var/log/sentinelle-cron.log 2>&1"
;;
*)
main
;;
esac
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
source "${PROJECT_DIR}/tools/cockpit/json_contract.sh"
LOG_DIR="${PROJECT_DIR}/artifacts/cockpit"
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
REGISTRY_FILE="${PROJECT_DIR}/specs/contracts/machine_registry.mesh.json"
JSON_OUTPUT=0
VERBOSE=0
LOG_FILE="${LOG_DIR}/ssh_healthcheck_${TIMESTAMP}.log"
TARGETS=()
usage() {
cat <<'USAGE'
Usage: bash tools/cockpit/ssh_healthcheck.sh [options]
Options:
--json Emit a JSON summary to stdout.
--no-log Disable log file creation.
--verbose Show command-level details.
-h, --help Show this help message.
This script checks SSH reachability on the project-operator machines.
Each JSON result now includes `priority`, `port` and `role` for audit traceability.
USAGE
}
log_line() {
local level="$1"
shift
local msg="${level} $(date '+%Y-%m-%d %H:%M:%S %z') ${*}"
printf '%s\n' "${msg}"
if [[ -n "${LOG_FILE:-}" ]]; then
printf '%s\n' "${msg}" >> "${LOG_FILE}"
fi
}
load_targets_from_registry() {
python3 - "${REGISTRY_FILE}" <<'PY'
import json
import sys
from pathlib import Path
registry_path = Path(sys.argv[1])
data = json.loads(registry_path.read_text(encoding="utf-8"))
targets = sorted(data.get("targets", []), key=lambda item: item.get("priority", 999))
for item in targets:
target = item.get("target", "")
port = int(item.get("port", 0) or 0)
if target == "local" or port <= 0:
continue
priority = item.get("priority", 999)
role = item.get("role", "unknown")
print(f"{target}|{port}|{priority}|{role}")
PY
}
run_target() {
local target_host
local port
local priority
local role
local entry="$1"
local ok=0
IFS='|' read -r target_host port priority role <<< "${entry}"
local ssh_cmd=(
ssh
-o BatchMode=yes
-o ConnectTimeout=5
-o StrictHostKeyChecking=accept-new
-p "${port}"
"${target_host}"
'echo OK'
)
if [[ "${VERBOSE}" -eq 1 ]]; then
if "${ssh_cmd[@]}" >/dev/null; then
ok=1
fi
else
if "${ssh_cmd[@]}" >/dev/null 2>&1; then
ok=1
fi
fi
if [[ "${ok}" -eq 1 ]]; then
log_line "INFO" "[OK] P${priority} ${role} -> ${target_host} (port ${port})"
else
log_line "WARN" "[KO] P${priority} ${role} -> ${target_host} (port ${port})"
fi
RESULTS+=("${target_host}|${port}|${priority}|${role}|${ok}")
}
while [[ $# -gt 0 ]]; do
case "$1" in
--json)
JSON_OUTPUT=1
;;
--no-log)
LOG_FILE=""
;;
--verbose)
VERBOSE=1
;;
-h|--help)
usage
exit 0
;;
*)
usage >&2
exit 2
;;
esac
shift
done
if [[ -n "${LOG_FILE}" ]]; then
mkdir -p "${LOG_DIR}"
touch "${LOG_DIR}/.keep"
log_line "INFO" "Log file: ${LOG_FILE}"
fi
log_line "INFO" "Starting Kill_LIFE SSH health-check"
mapfile -t TARGETS < <(load_targets_from_registry)
if [[ "${#TARGETS[@]}" -eq 0 ]]; then
log_line "WARN" "No SSH targets resolved from ${REGISTRY_FILE}"
exit 1
fi
RESULTS=()
for entry in "${TARGETS[@]}"; do
run_target "${entry}"
done
ok_count=0
ko_count=0
for r in "${RESULTS[@]}"; do
ok_count=$((ok_count + $(awk -F'|' '{print $5}' <<< "${r}")))
done
ko_count=$((${#TARGETS[@]} - ok_count))
log_line "INFO" "Completed: ${ok_count} OK, ${ko_count} KO"
for r in "${RESULTS[@]}"; do
IFS='|' read -r target_host port priority role ok <<< "${r}"
log_line "INFO" " - P${priority} ${target_host} (${port}) ${role} => $([[ "${ok}" -eq 1 ]] && echo OK || echo KO)"
done
if [[ "${JSON_OUTPUT}" -eq 1 ]]; then
contract_status="ok"
degraded_reasons=()
next_steps=()
artifacts=()
[[ -n "${LOG_FILE}" ]] && artifacts+=("${LOG_FILE}")
if [[ "${ko_count}" -gt 0 ]]; then
contract_status="degraded"
degraded_reasons+=("ssh-target-unreachable")
next_steps+=("Inspect SSH access, port 22 reachability and operator keys on KO targets.")
fi
printf '{\n'
printf ' "contract_version": "cockpit-v1",\n'
printf ' "component": "ssh_healthcheck",\n'
printf ' "action": "summary",\n'
printf ' "status": "%s",\n' "${contract_status}"
printf ' "contract_status": "%s",\n' "${contract_status}"
printf ' "generated_at": "%s",\n' "$(date '+%Y-%m-%d %H:%M:%S %z')"
printf ' "log_file": "%s",\n' "${LOG_FILE}"
printf ' "registry_file": "%s",\n' "${REGISTRY_FILE}"
printf ' "target_source": "registry",\n'
printf ' "artifacts": %s,\n' "$(json_contract_array_from_args "${artifacts[@]}")"
printf ' "degraded_reasons": %s,\n' "$(json_contract_array_from_args "${degraded_reasons[@]}")"
printf ' "next_steps": %s,\n' "$(json_contract_array_from_args "${next_steps[@]}")"
printf ' "ok_count": %d,\n' "${ok_count}"
printf ' "ko_count": %d,\n' "${ko_count}"
printf ' "targets": [\n'
for ((idx = 0; idx < ${#RESULTS[@]}; idx++)); do
IFS='|' read -r target_host port priority role ok <<< "${RESULTS[${idx}]}"
status="ok"
[[ "${ok}" -eq 0 ]] && status="ko"
comma=","
if [[ ${idx} -eq $(( ${#RESULTS[@]} - 1 )) ]]; then
comma=""
fi
printf ' {"target":"%s","port":"%s","priority":"%s","role":"%s","status":"%s"}%s\n' \
"${target_host}" "${port}" "${priority}" "${role}" "${status}" "${comma}"
done
printf ' ]\n'
printf '}\n'
fi
if [[ "${ok_count}" -eq "${#TARGETS[@]}" ]]; then
log_line "INFO" "SSH health-check: all targets reachable"
exit 0
else
log_line "WARN" "SSH health-check: one or more targets unreachable"
exit 2
fi
@@ -0,0 +1,231 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
MEMORY_DIR="${ROOT_DIR}/artifacts/cockpit/kill_life_memory"
COMPONENT="unknown"
STATUS="degraded"
OWNER="SyncOps"
DECISION_ACTION="record-memory-entry"
DECISION_REASON="Persist execution continuity in kill_life."
NEXT_STEP="Review the latest cockpit artifact."
RESUME_REF=""
TRUST_LEVEL="inferred"
ROUTING_FILE=""
JSON_OUTPUT=0
ARTIFACTS=()
usage() {
cat <<'USAGE'
Usage: bash tools/cockpit/write_kill_life_memory_entry.sh [options]
Options:
--component NAME
--status ok|degraded|error|blocked
--owner NAME
--decision-action TEXT
--decision-reason TEXT
--next-step TEXT
--resume-ref TEXT
--trust-level verified|bounded|inferred
--routing-file FILE
--artifact PATH Repeatable
--json
-h, --help
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--component)
COMPONENT="${2:-}"
shift 2
;;
--status)
STATUS="${2:-}"
shift 2
;;
--owner)
OWNER="${2:-}"
shift 2
;;
--decision-action)
DECISION_ACTION="${2:-}"
shift 2
;;
--decision-reason)
DECISION_REASON="${2:-}"
shift 2
;;
--next-step)
NEXT_STEP="${2:-}"
shift 2
;;
--resume-ref)
RESUME_REF="${2:-}"
shift 2
;;
--trust-level)
TRUST_LEVEL="${2:-}"
shift 2
;;
--routing-file)
ROUTING_FILE="${2:-}"
shift 2
;;
--artifact)
ARTIFACTS+=("${2:-}")
shift 2
;;
--json)
JSON_OUTPUT=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
mkdir -p "${MEMORY_DIR}"
timestamp="$(date '+%Y%m%d_%H%M%S')"
entry_json="${MEMORY_DIR}/${COMPONENT}_${timestamp}.json"
entry_md="${MEMORY_DIR}/${COMPONENT}_${timestamp}.md"
latest_json="${MEMORY_DIR}/latest.json"
latest_md="${MEMORY_DIR}/latest.md"
component_latest_json="${MEMORY_DIR}/${COMPONENT}_latest.json"
component_latest_md="${MEMORY_DIR}/${COMPONENT}_latest.md"
intelligence_json="${ROOT_DIR}/artifacts/cockpit/intelligence_program/latest.json"
intelligence_md="${ROOT_DIR}/artifacts/cockpit/intelligence_program/latest.md"
if [[ ! -f "${intelligence_json}" || ! -f "${intelligence_md}" ]]; then
bash "${ROOT_DIR}/tools/cockpit/intelligence_tui.sh" --action memory --json >/dev/null 2>/dev/null || true
fi
ARTIFACTS_JSON="$(
python3 - "${ARTIFACTS[@]}" <<'PY'
import json
import sys
print(json.dumps([item for item in sys.argv[1:] if item], ensure_ascii=True))
PY
)"
ARTIFACTS_JSON="${ARTIFACTS_JSON}" python3 - "${entry_json}" "${entry_md}" "${latest_json}" "${latest_md}" "${component_latest_json}" "${component_latest_md}" "${COMPONENT}" "${STATUS}" "${OWNER}" "${DECISION_ACTION}" "${DECISION_REASON}" "${NEXT_STEP}" "${RESUME_REF}" "${TRUST_LEVEL}" "${ROUTING_FILE}" "${intelligence_json}" "${intelligence_md}" <<'PY'
import json
import os
import sys
from pathlib import Path
entry_json = Path(sys.argv[1])
entry_md = Path(sys.argv[2])
latest_json = Path(sys.argv[3])
latest_md = Path(sys.argv[4])
component_latest_json = Path(sys.argv[5])
component_latest_md = Path(sys.argv[6])
component = sys.argv[7]
status = sys.argv[8]
owner = sys.argv[9]
decision_action = sys.argv[10]
decision_reason = sys.argv[11]
next_step = sys.argv[12]
resume_ref = sys.argv[13]
trust_level = sys.argv[14]
routing_file = Path(sys.argv[15]) if sys.argv[15] else None
intelligence_json = Path(sys.argv[16])
intelligence_md = Path(sys.argv[17])
artifacts = json.loads(os.environ.get("ARTIFACTS_JSON", "[]"))
routing = {}
if routing_file and routing_file.exists():
try:
routing = json.loads(routing_file.read_text(encoding="utf-8"))
except json.JSONDecodeError:
routing = {"status": "invalid-json", "file": str(routing_file)}
intelligence_memory = {
"json": str(intelligence_json) if intelligence_json.exists() else "",
"markdown": str(intelligence_md) if intelligence_md.exists() else "",
}
entry = {
"status": status,
"component": component,
"owner": owner,
"decision": {
"action": decision_action,
"reason": decision_reason,
},
"next_step": next_step,
"resume_ref": resume_ref,
"trust_level": trust_level,
"routing": routing,
"artifacts": artifacts,
"intelligence_memory": intelligence_memory,
}
summary = {
"status": "ok",
"component": component,
"entry": entry,
"artifacts": artifacts,
"entry_file": str(entry_json),
"markdown_file": str(entry_md),
"latest_json": str(latest_json),
"latest_markdown": str(latest_md),
"component_latest_json": str(component_latest_json),
"component_latest_markdown": str(component_latest_md),
"resume_ref": resume_ref,
"trust_level": trust_level,
"intelligence_memory": intelligence_memory,
}
entry_md.write_text(
"\n".join(
(
[
f"# kill_life memory entry - {component}",
"",
f"- status: {status}",
f"- owner: {owner}",
f"- trust_level: {trust_level}",
f"- resume_ref: {resume_ref}",
f"- next_step: {next_step}",
"",
"## Decision",
f"- action: {decision_action}",
f"- reason: {decision_reason}",
"",
"## Routing",
f"- selected_target: {routing.get('selected_target', 'unknown')}",
f"- selected_host: {routing.get('selected_host', 'unknown')}",
f"- family: {routing.get('family', 'unknown')}",
"",
"## Intelligence memory",
f"- json: {intelligence_memory['json'] or 'missing'}",
f"- markdown: {intelligence_memory['markdown'] or 'missing'}",
"",
"## Artifacts",
]
+ ([f"- {item}" for item in artifacts] if artifacts else ["- none"])
)
)
+ "\n",
encoding="utf-8",
)
entry_json.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
latest_json.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
latest_md.write_text(entry_md.read_text(encoding="utf-8"), encoding="utf-8")
component_latest_json.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
component_latest_md.write_text(entry_md.read_text(encoding="utf-8"), encoding="utf-8")
print(json.dumps(summary, ensure_ascii=False))
PY
+448
View File
@@ -0,0 +1,448 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ARTIFACTS_DIR="${ROOT_DIR}/artifacts/yiacad_backend_proof"
PROOF_FIXTURES_DIR="${ROOT_DIR}/tools/cad/proof_fixtures/yiacad_backend_proof"
mkdir -p "${ARTIFACTS_DIR}"
ACTION=""
JSON_MODE=0
VERBOSE=0
YES=0
DAYS=14
LINES=80
usage() {
cat <<'EOF'
Usage: yiacad_backend_proof.sh --action <run|status|logs-summary|logs-list|logs-latest|purge-logs> [options]
Options:
--action <name> Action to run
--days <N> Retention window for purge-logs (default: 14)
--lines <N> Number of lines for logs-latest (default: 80)
--json Emit JSON for run/status/logs-summary
--yes Confirm destructive purge in non-interactive mode
--verbose Print executed commands
--help Show this help
EOF
}
have_tty() {
[[ -t 0 && -t 1 ]]
}
log_cmd() {
if [[ "${VERBOSE}" -eq 1 ]]; then
printf '[cmd] %s\n' "$*"
fi
}
collect_log_files() {
find "${ARTIFACTS_DIR}" -type f 2>/dev/null
}
latest_summary_json() {
if [[ -f "${ARTIFACTS_DIR}/latest.json" ]]; then
printf '%s\n' "${ARTIFACTS_DIR}/latest.json"
return 0
fi
find "${ARTIFACTS_DIR}" -type f -name 'summary.json' 2>/dev/null | sort | tail -n 1
}
latest_summary_md() {
if [[ -f "${ARTIFACTS_DIR}/latest.md" ]]; then
printf '%s\n' "${ARTIFACTS_DIR}/latest.md"
return 0
fi
find "${ARTIFACTS_DIR}" -type f -name 'summary.md' 2>/dev/null | sort | tail -n 1
}
run_proof() {
local stamp run_dir proof_status
stamp="$(date +%Y%m%d_%H%M%S)"
run_dir="${ARTIFACTS_DIR}/${stamp}"
mkdir -p "${run_dir}"
log_cmd python3 "${ROOT_DIR}/tools/cad/yiacad_backend_client.py" --json-output status
python3 "${ROOT_DIR}/tools/cad/yiacad_backend_client.py" --json-output status > "${run_dir}/backend_status.json"
log_cmd python3 "${ROOT_DIR}/tools/cad/yiacad_backend_client.py" --json-output status
python3 "${ROOT_DIR}/tools/cad/yiacad_backend_client.py" --json-output status > "${run_dir}/backend_invoke_status.json"
log_cmd python3 kicad transport proof
ROOT_DIR="${ROOT_DIR}" python3 - <<'PY' > "${run_dir}/kicad_transport.json"
import importlib.util
import json
import os
from pathlib import Path
root = Path(os.environ["ROOT_DIR"])
mod_path = root / ".runtime-home" / "cad-ai-native-forks" / "kicad-ki" / "scripting" / "plugins" / "yiacad_kicad_plugin" / "_native_common.py"
spec = importlib.util.spec_from_file_location("yiacad_kicad_native_common_proof", mod_path)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
result = module.run_native_action("bom-review", "--source-path", "/tmp/nonexistent.kicad_pcb", "--json-output")
proof_source = str(root / "tools" / "cad" / "proof_fixtures" / "yiacad_backend_proof" / "probe_board.kicad_pcb")
result = module.run_native_action("bom-review", "--source-path", proof_source, "--json-output")
payload = json.loads((result.stdout or "{}").strip() or "{}")
required = ["component", "surface", "action", "execution_mode", "status", "severity", "summary", "artifacts", "next_steps"]
missing = [field for field in required if field not in payload]
payload_status = payload.get("status")
proof = {
"transport": "kicad-shell",
"status": "done" if not missing and payload_status in {"done", "degraded", "blocked"} else "blocked",
"returncode": result.returncode,
"payload_status": payload_status,
"contract_ok": not missing,
"missing_fields": missing,
"payload": payload,
}
print(json.dumps(proof, indent=2, ensure_ascii=False))
PY
log_cmd python3 freecad transport proof
ROOT_DIR="${ROOT_DIR}" python3 - <<'PY' > "${run_dir}/freecad_transport.json"
import importlib.util
import json
import os
import sys
import types
from pathlib import Path
root = Path(os.environ["ROOT_DIR"])
mod_path = root / ".runtime-home" / "cad-ai-native-forks" / "freecad-ki" / "src" / "Mod" / "YiACADWorkbench" / "yiacad_freecad_gui.py"
freecad = types.ModuleType("FreeCAD")
freecad.ActiveDocument = None
freecad_gui = types.ModuleType("FreeCADGui")
freecad_gui.runCommand = lambda *args, **kwargs: None
freecad_gui.addCommand = lambda *args, **kwargs: None
pyside = types.ModuleType("PySide")
qtcore = types.ModuleType("QtCore")
qtgui = types.ModuleType("QtGui")
pyside.QtCore = qtcore
pyside.QtGui = qtgui
sys.modules["FreeCAD"] = freecad
sys.modules["FreeCADGui"] = freecad_gui
sys.modules["PySide"] = pyside
sys.modules["PySide.QtCore"] = qtcore
sys.modules["PySide.QtGui"] = qtgui
spec = importlib.util.spec_from_file_location("yiacad_freecad_gui_proof", mod_path)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
payload = module.run_native_json_action("bom-review", "--source-path", "/tmp/nonexistent.FCStd")
proof_source = str(root / "tools" / "cad" / "proof_fixtures" / "yiacad_backend_proof" / "probe_model.FCStd")
payload = module.run_native_json_action("bom-review", "--source-path", proof_source)
required = ["component", "surface", "action", "execution_mode", "status", "severity", "summary", "artifacts", "next_steps"]
missing = [field for field in required if field not in payload]
proof = {
"transport": "freecad-workbench",
"status": "done" if not missing else "blocked",
"contract_ok": not missing,
"missing_fields": missing,
"payload": payload,
}
print(json.dumps(proof, indent=2, ensure_ascii=False))
PY
log_cmd python3 build backend proof summary
ROOT_DIR="${ROOT_DIR}" RUN_DIR="${run_dir}" python3 - <<'PY'
import json
import os
from pathlib import Path
run_dir = Path(os.environ["RUN_DIR"])
backend_status = json.loads((run_dir / "backend_status.json").read_text(encoding="utf-8"))
backend_invoke = json.loads((run_dir / "backend_invoke_status.json").read_text(encoding="utf-8"))
kicad = json.loads((run_dir / "kicad_transport.json").read_text(encoding="utf-8"))
freecad = json.loads((run_dir / "freecad_transport.json").read_text(encoding="utf-8"))
backend_ok = backend_status.get("status") in {"done", "degraded"}
invoke_ok = backend_invoke.get("status") in {"done", "degraded"}
kicad_ok = bool(kicad.get("contract_ok")) and kicad.get("status") == "done"
freecad_ok = bool(freecad.get("contract_ok")) and freecad.get("status") == "done"
overall_status = "done" if backend_ok and invoke_ok and kicad_ok and freecad_ok else "blocked"
degraded_reasons = []
if backend_status.get("status") == "degraded":
degraded_reasons.append("backend-facade-no-recent-runs")
if backend_invoke.get("status") == "degraded":
degraded_reasons.append("backend-invoke-status-degraded")
summary = {
"component": "yiacad-backend-proof",
"action": "run",
"status": overall_status,
"transport": "local-facade",
"backend_status": backend_status.get("status"),
"backend_invoke_status": backend_invoke.get("status"),
"kicad_transport_status": kicad.get("status"),
"freecad_transport_status": freecad.get("status"),
"contract_ok": bool(kicad.get("contract_ok")) and bool(freecad.get("contract_ok")),
"degraded_reasons": degraded_reasons,
"artifacts": [
{"kind": "report", "path": str(run_dir / "backend_status.json"), "label": "Backend facade status"},
{"kind": "report", "path": str(run_dir / "backend_invoke_status.json"), "label": "Backend facade invoke status"},
{"kind": "evidence", "path": str(run_dir / "kicad_transport.json"), "label": "KiCad shell transport proof"},
{"kind": "evidence", "path": str(run_dir / "freecad_transport.json"), "label": "FreeCAD workbench transport proof"},
{"kind": "report", "path": str(run_dir / "summary.md"), "label": "Unified operator proof summary"},
],
"next_steps": [
"promote the proof runbook via yiacad_operator_index.sh",
"bind review center and inspector to uiux_output.json everywhere",
"keep yiacad-fusion blocked until the KiCad host entrypoint exists or container fallback is accepted",
],
}
summary_json = run_dir / "summary.json"
summary_json.write_text(json.dumps(summary, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
md = [
"# YiACAD Backend Operator Proof",
"",
f"- status: {summary['status']}",
f"- transport: {summary['transport']}",
f"- backend_status: {summary['backend_status']}",
f"- backend_invoke_status: {summary['backend_invoke_status']}",
f"- kicad_transport_status: {summary['kicad_transport_status']}",
f"- freecad_transport_status: {summary['freecad_transport_status']}",
f"- contract_ok: {'yes' if summary['contract_ok'] else 'no'}",
"",
"## Artifacts",
]
for artifact in summary["artifacts"]:
md.append(f"- {artifact['label']}: {artifact['path']}")
md.extend(["", "## Next steps"])
for step in summary["next_steps"]:
md.append(f"- {step}")
(run_dir / "summary.md").write_text("\n".join(md) + "\n", encoding="utf-8")
PY
cp "${run_dir}/summary.json" "${ARTIFACTS_DIR}/latest.json"
cp "${run_dir}/summary.md" "${ARTIFACTS_DIR}/latest.md"
if [[ "${JSON_MODE}" -eq 1 ]]; then
cat "${run_dir}/summary.json"
else
cat "${run_dir}/summary.md"
fi
proof_status="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["status"])' "${run_dir}/summary.json")"
[[ "${proof_status}" == "done" ]]
}
show_status() {
local latest_json latest_md
latest_json="$(latest_summary_json)"
latest_md="$(latest_summary_md)"
if [[ -z "${latest_json}" || -z "${latest_md}" ]]; then
if [[ "${JSON_MODE}" -eq 1 ]]; then
python3 - <<PY
import json
print(json.dumps({
"component": "yiacad-backend-proof",
"action": "status",
"status": "degraded",
"summary": "No unified backend proof has been generated yet.",
"artifacts_dir": "${ARTIFACTS_DIR}",
}, ensure_ascii=False))
PY
else
cat <<EOF
# YiACAD Backend Operator Proof
- status: degraded
- summary: No unified backend proof has been generated yet.
- artifacts_dir: ${ARTIFACTS_DIR}
EOF
fi
return 0
fi
if [[ "${JSON_MODE}" -eq 1 ]]; then
cat "${latest_json}"
else
cat "${latest_md}"
fi
}
logs_list() {
local found=0
local path=""
while IFS= read -r path; do
[[ -n "${path}" ]] || continue
[[ "${path}" == "${LOG_FILE:-}" ]] && continue
printf '%s\n' "${path}"
found=1
done < <(collect_log_files | sort)
if [[ "${found}" -eq 0 ]]; then
if [[ -n "${LOG_FILE:-}" ]]; then
printf '%s\n' "${LOG_FILE}"
else
printf 'no logs found\n'
fi
fi
}
logs_latest() {
local latest=""
local path=""
while IFS= read -r path; do
[[ -n "${path}" ]] || continue
[[ "${path}" == "${LOG_FILE:-}" ]] && continue
latest="${path}"
done < <(collect_log_files | sort)
if [[ -z "${latest}" ]]; then
if [[ -n "${LOG_FILE:-}" ]]; then
latest="${LOG_FILE}"
else
printf 'no logs found\n'
return 1
fi
fi
printf '# Latest log\n\n'
printf -- '- path: %s\n' "${latest}"
printf -- '- lines: %s\n\n' "${LINES}"
tail -n "${LINES}" "${latest}"
}
logs_summary() {
local log_count
log_count="$(find "${ARTIFACTS_DIR}" -type f | wc -l | tr -d ' ')"
if [[ "${JSON_MODE}" -eq 1 ]]; then
python3 - <<PY
import json
print(json.dumps({
"status": "done",
"component": "yiacad-backend-proof",
"log_files": int("${log_count}"),
"artifacts_dir": "${ARTIFACTS_DIR}",
"latest_summary": "${ARTIFACTS_DIR}/latest.json",
}, ensure_ascii=False))
PY
return 0
fi
cat <<EOF
# YiACAD backend proof logs summary
- log files: ${log_count}
- artifacts dir: ${ARTIFACTS_DIR}
- latest summary: ${ARTIFACTS_DIR}/latest.json
EOF
}
purge_logs() {
if [[ "${YES}" -ne 1 ]]; then
if command -v gum >/dev/null 2>&1 && have_tty; then
if ! gum confirm "Purger les preuves/logs backend YiACAD de plus de ${DAYS} jours ?"; then
printf 'purge cancelled\n'
return 0
fi
else
printf 'Refusing purge without --yes outside interactive confirm\n' >&2
return 2
fi
fi
find "${ARTIFACTS_DIR}" -type f -mtime +"${DAYS}" -delete
find "${ARTIFACTS_DIR}" -mindepth 1 -type d -empty -delete
printf 'purged yiacad backend proof logs older than %s days in %s\n' "${DAYS}" "${ARTIFACTS_DIR}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--days)
DAYS="${2:-}"
shift 2
;;
--lines)
LINES="${2:-}"
shift 2
;;
--json)
JSON_MODE=1
shift
;;
--yes)
YES=1
shift
;;
--verbose)
VERBOSE=1
shift
;;
--help)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
if [[ -z "${ACTION}" ]]; then
printf 'Missing --action\n' >&2
usage >&2
exit 2
fi
if ! [[ "${DAYS}" =~ ^[0-9]+$ ]]; then
printf -- '--days requires an integer\n' >&2
exit 2
fi
if ! [[ "${LINES}" =~ ^[0-9]+$ ]]; then
printf -- '--lines requires an integer\n' >&2
exit 2
fi
STAMP="$(date +%Y%m%d_%H%M%S)"
LOG_FILE="${ARTIFACTS_DIR}/yiacad_backend_proof_${STAMP}.log"
exec > >(tee -a "${LOG_FILE}") 2>&1
if [[ "${JSON_MODE}" -ne 1 ]]; then
printf '[yiacad-backend-proof] action=%s timestamp=%s\n' "${ACTION}" "${STAMP}"
fi
case "${ACTION}" in
run)
run_proof
;;
status)
show_status
;;
logs-summary)
logs_summary
;;
logs-list)
logs_list
;;
logs-latest)
logs_latest
;;
purge-logs)
purge_logs
;;
*)
printf 'Unknown action: %s\n' "${ACTION}" >&2
exit 2
;;
esac
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SERVICE_ARTIFACTS_DIR="${ROOT_DIR}/artifacts/cad-ai-native/service"
TUI_ARTIFACTS_DIR="${ROOT_DIR}/artifacts/yiacad_backend_service_tui"
mkdir -p "${TUI_ARTIFACTS_DIR}"
ACTION=""
JSON_MODE=0
YES=0
DAYS=14
LINES=80
usage() {
cat <<'EOF'
Usage: yiacad_backend_service_tui.sh --action <status|health|logs-summary|logs-list|logs-latest|purge-logs> [options]
Options:
--action <name> Action to run
--days <N> Retention window for purge-logs (default: 14)
--lines <N> Number of lines for logs-latest (default: 80)
--json Emit JSON for health/logs-summary
--yes Confirm destructive purge in non-interactive mode
--help Show this help
EOF
}
have_tty() {
[[ -t 0 && -t 1 ]]
}
choose_action_interactive() {
if command -v gum >/dev/null 2>&1 && have_tty; then
gum choose status health logs-summary logs-list logs-latest purge-logs
return 0
fi
return 1
}
collect_log_files() {
find "${SERVICE_ARTIFACTS_DIR}" -type f 2>/dev/null
find "${TUI_ARTIFACTS_DIR}" -type f 2>/dev/null
}
service_health() {
if [[ "${JSON_MODE}" -eq 1 ]]; then
python3 "${ROOT_DIR}/tools/cad/yiacad_backend_client.py" --json-output health 2>/dev/null || printf '{"status":"blocked"}\n'
else
if ! python3 "${ROOT_DIR}/tools/cad/yiacad_backend_client.py" --json-output health 2>/dev/null; then
printf '# YiACAD backend service health\n\n- status: blocked\n'
fi
fi
}
status_view() {
cat <<EOF
# YiACAD backend service status
- operator_index:
- tools/cockpit/yiacad_operator_index.sh --action status
- mode:
- service-first
- health:
- python3 tools/cad/yiacad_backend_client.py --json-output health
- service_artifacts:
- ${SERVICE_ARTIFACTS_DIR}
- latest_server:
- ${SERVICE_ARTIFACTS_DIR}/latest_server.json
- latest_health:
- ${SERVICE_ARTIFACTS_DIR}/latest_health.json
- latest_response:
- ${SERVICE_ARTIFACTS_DIR}/latest_response.json
EOF
}
logs_list() {
local found=0
local path=""
while IFS= read -r path; do
[[ -n "${path}" ]] || continue
[[ "${path}" == "${LOG_FILE:-}" ]] && continue
printf '%s\n' "${path}"
found=1
done < <(collect_log_files | sort)
if [[ "${found}" -eq 0 ]]; then
printf 'no logs found\n'
fi
}
logs_latest() {
local latest=""
local path=""
while IFS= read -r path; do
[[ -n "${path}" ]] || continue
[[ "${path}" == "${LOG_FILE:-}" ]] && continue
latest="${path}"
done < <(collect_log_files | sort)
if [[ -z "${latest}" ]]; then
printf 'no logs found\n'
return 1
fi
printf '# Latest log\n\n'
printf -- '- path: %s\n' "${latest}"
printf -- '- lines: %s\n\n' "${LINES}"
tail -n "${LINES}" "${latest}"
}
logs_summary() {
local service_count tui_count
service_count="$(find "${SERVICE_ARTIFACTS_DIR}" -type f 2>/dev/null | wc -l | tr -d ' ')"
tui_count="$(find "${TUI_ARTIFACTS_DIR}" -type f 2>/dev/null | wc -l | tr -d ' ')"
if [[ "${JSON_MODE}" -eq 1 ]]; then
python3 - <<PY
import json
print(json.dumps({
"status": "done",
"service_artifact_files": int("${service_count}"),
"tui_log_files": int("${tui_count}"),
"service_artifacts_dir": "${SERVICE_ARTIFACTS_DIR}",
"tui_artifacts_dir": "${TUI_ARTIFACTS_DIR}",
}, ensure_ascii=False))
PY
return 0
fi
cat <<EOF
# YiACAD backend service logs summary
- service artifact files: ${service_count}
- tui log files: ${tui_count}
- service artifacts dir: ${SERVICE_ARTIFACTS_DIR}
- tui artifacts dir: ${TUI_ARTIFACTS_DIR}
EOF
}
purge_logs() {
if [[ "${YES}" -ne 1 ]]; then
if command -v gum >/dev/null 2>&1 && have_tty; then
if ! gum confirm "Purger les logs backend service YiACAD de plus de ${DAYS} jours ?"; then
printf 'purge cancelled\n'
return 0
fi
else
printf 'Refusing purge without --yes outside interactive confirm\n' >&2
return 2
fi
fi
find "${SERVICE_ARTIFACTS_DIR}" -type f -mtime +"${DAYS}" -delete 2>/dev/null || true
find "${TUI_ARTIFACTS_DIR}" -type f -mtime +"${DAYS}" -delete 2>/dev/null || true
printf 'purged backend service logs older than %s days\n' "${DAYS}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--days)
DAYS="${2:-}"
shift 2
;;
--lines)
LINES="${2:-}"
shift 2
;;
--json)
JSON_MODE=1
shift
;;
--yes)
YES=1
shift
;;
--help)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
if [[ -z "${ACTION}" ]]; then
if ACTION="$(choose_action_interactive)"; then
:
else
printf 'Missing --action (interactive selection unavailable)\n' >&2
usage >&2
exit 2
fi
fi
STAMP="$(date +%Y%m%d_%H%M%S)"
LOG_FILE="${TUI_ARTIFACTS_DIR}/yiacad_backend_service_tui_${STAMP}.log"
exec > >(tee -a "${LOG_FILE}") 2>&1
case "${ACTION}" in
status)
status_view
;;
health)
service_health
;;
logs-summary)
logs_summary
;;
logs-list)
logs_list
;;
logs-latest)
logs_latest
;;
purge-logs)
purge_logs
;;
*)
printf 'Unknown action: %s\n' "${ACTION}" >&2
exit 2
;;
esac
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ARTIFACTS_DIR="${ROOT_DIR}/artifacts/yiacad_logs_tui"
ACTION="status"
DAYS="14"
YES="false"
JSON="false"
STAMP="$(date +%Y%m%d-%H%M%S)"
LOG_FILE=""
LOG_SOURCES=(
"${ROOT_DIR}/artifacts/yiacad_operator_index"
"${ROOT_DIR}/artifacts/yiacad_uiux_tui"
"${ROOT_DIR}/artifacts/yiacad_backend_service_tui"
"${ROOT_DIR}/artifacts/yiacad_proofs_tui"
"${ROOT_DIR}/artifacts/cad-ai-native/service"
)
usage() {
cat <<'USAGE'
Usage: yiacad_logs_tui.sh --action <status|summary|list|latest|purge-logs> [options]
Options:
--action ACTION Action to execute.
--days N Purge logs older than N days. Default: 14.
--json Emit JSON status for machine use.
--yes Confirm destructive actions like purge.
--help Show this help.
USAGE
}
ensure_dirs() {
mkdir -p "${ARTIFACTS_DIR}"
LOG_FILE="${ARTIFACTS_DIR}/yiacad_logs_tui_${STAMP}.log"
touch "${LOG_FILE}"
}
log() {
printf '[yiacad-logs] %s\n' "$*" | tee -a "${LOG_FILE}" >&2
}
collect_logs() {
local source
for source in "${LOG_SOURCES[@]}"; do
if [[ -d "${source}" ]]; then
find "${source}" -maxdepth 2 -type f \( -name '*.log' -o -name 'server.log' \) -print
fi
done | sort -u
}
render_status_text() {
cat <<EOF2
YiACAD logs status
- canonical_entry: bash tools/cockpit/yiacad_logs_tui.sh --action status
- operator_entry: bash tools/cockpit/yiacad_operator_index.sh --action status
- source_count: $(printf '%s
' "${LOG_SOURCES[@]}" | wc -l | tr -d ' ')
- action_hint: utilisez --action summary pour un agregat et --action purge-logs --days 14 --yes pour nettoyer
EOF2
}
render_status_json() {
cat <<EOF2
{
"component": "yiacad-logs-tui",
"timestamp": "${STAMP}",
"canonical_entry": "bash tools/cockpit/yiacad_logs_tui.sh --action status",
"operator_entry": "bash tools/cockpit/yiacad_operator_index.sh --action status",
"source_count": $(printf '%s\n' "${LOG_SOURCES[@]}" | wc -l | tr -d ' ')
}
EOF2
}
summary_action() {
local tmp count latest
tmp="$(mktemp)"
collect_logs > "${tmp}"
count="$(wc -l < "${tmp}" | tr -d ' ')"
latest="$(tail -n 1 "${tmp}")"
if [[ "${JSON}" == "true" ]]; then
cat <<EOF2
{
"component": "yiacad-logs-tui",
"log_count": ${count:-0},
"latest_log": "${latest}",
"sources": [
"${ROOT_DIR}/artifacts/yiacad_operator_index",
"${ROOT_DIR}/artifacts/yiacad_uiux_tui",
"${ROOT_DIR}/artifacts/yiacad_backend_service_tui",
"${ROOT_DIR}/artifacts/yiacad_proofs_tui",
"${ROOT_DIR}/artifacts/cad-ai-native/service"
]
}
EOF2
else
printf 'YiACAD aggregated logs\n'
printf -- '- log_count: %s\n' "${count:-0}"
printf -- '- latest_log: %s\n' "${latest:-none}"
fi
rm -f "${tmp}"
}
list_action() {
collect_logs
}
latest_action() {
local latest
latest="$(collect_logs | tail -n 1)"
if [[ -n "${latest}" ]]; then
cat "${latest}"
fi
}
purge_action() {
local source
if [[ "${YES}" != "true" ]]; then
printf 'Refusing to purge logs without --yes\n' >&2
exit 1
fi
for source in "${LOG_SOURCES[@]}"; do
if [[ -d "${source}" ]]; then
find "${source}" -maxdepth 2 -type f \( -name '*.log' -o -name 'server.log' \) -mtime "+${DAYS}" -delete
fi
done
printf 'Purged YiACAD logs older than %s days\n' "${DAYS}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--days)
DAYS="${2:-}"
shift 2
;;
--json)
JSON="true"
shift
;;
--yes)
YES="true"
shift
;;
--help)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 1
;;
esac
done
ensure_dirs
case "${ACTION}" in
status)
if [[ "${JSON}" == "true" ]]; then
render_status_json
else
render_status_text
fi
;;
summary)
summary_action
;;
list)
list_action
;;
latest)
latest_action
;;
purge-logs)
purge_action
;;
*)
printf 'Unknown action: %s\n' "${ACTION}" >&2
usage >&2
exit 1
;;
esac
+551
View File
@@ -0,0 +1,551 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ARTIFACTS_DIR="${ROOT_DIR}/artifacts/yiacad_operator_index"
mkdir -p "${ARTIFACTS_DIR}"
ACTION=""
JSON_MODE=0
VERBOSE=0
YES=0
DAYS=14
LINES=80
usage() {
cat <<'EOF'
Usage: yiacad_operator_index.sh --action <action> [options]
Actions:
status View operator index status and routes
incident-watch Monitor incidents via mascarade_incidents_tui.sh
incident-history View incident history
uiux Run UI/UX status checks
global Run global refonte status
backend Backend service status
review-context Review context/session/history/taxonomy
proofs View proofs and continuity artifacts
logs-summary Summarize logs (supports --json)
logs-list List all log files
logs-latest Show latest log
purge-logs Clean old logs (--days N, requires --yes)
--- Mistral Agents (Lot 23) ---
agents-status Check all 4 Mistral agents status
agents-chat Chat with a Mistral agent interactively
agents-health Run Sentinelle full diagnostic
agents-e2e Run E2E agents integration tests
--- Mistral Studio (Lot 24) ---
studio-status Mistral Studio health overview (agents+files+ft+libraries)
studio-files List Mistral Files API
studio-finetune List fine-tune jobs status
studio-libraries List Document Libraries (Beta RAG)
studio-conversations List Beta Conversations
studio-models Full models catalog
infra-health Infrastructure container health check (web+docker)
Aliases: backend-proof, review-session, review-history, review-taxonomy
Options:
--action <name> Action to run
--days <N> Retention window for purge-logs (default: 14)
--lines <N> Number of lines for logs-latest (default: 80)
--json Emit JSON for logs-summary
--yes Confirm destructive purge in non-interactive mode
--verbose Print executed commands
--help Show this help
EOF
}
have_tty() {
[[ -t 0 && -t 1 ]]
}
choose_action_interactive() {
if command -v gum >/dev/null 2>&1 && have_tty; then
gum choose \
status \
incident-watch \
incident-history \
uiux \
global \
backend \
backend-proof \
review-session \
review-history \
review-taxonomy \
review-context \
proofs \
agents-status \
agents-chat \
agents-health \
agents-e2e \
studio-status \
studio-files \
studio-finetune \
studio-libraries \
studio-conversations \
studio-models \
infra-health \
logs-summary \
logs-list \
logs-latest \
purge-logs
return 0
fi
return 1
}
log_cmd() {
if [[ "${VERBOSE}" -eq 1 ]]; then
printf '[cmd] %s\n' "$*"
fi
}
run_uiux_status() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action lane-status
bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action lane-status
}
run_global_status() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_refonte_tui.sh" --action status
bash "${ROOT_DIR}/tools/cockpit/yiacad_refonte_tui.sh" --action status
}
run_backend_service() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_backend_service_tui.sh" --action status
bash "${ROOT_DIR}/tools/cockpit/yiacad_backend_service_tui.sh" --action status
}
run_backend_proof() {
run_backend_service
}
run_incident_watch() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/mascarade_incidents_tui.sh" --action watch
bash "${ROOT_DIR}/tools/cockpit/mascarade_incidents_tui.sh" --action watch
}
run_incident_history() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/render_mascarade_watch_history.sh"
bash "${ROOT_DIR}/tools/cockpit/render_mascarade_watch_history.sh"
}
run_review_session() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-session
bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-session
}
run_review_history() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-history
bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-history
}
run_review_taxonomy() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-taxonomy
bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-taxonomy
}
run_review_context() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-context
bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-context
}
run_review_context() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-context
bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-context
}
# ── Mistral Agents (Lot 23 T-MA-024) ─────────────────────────────────────
run_agents_status() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/mistral_agents_tui.sh" --action status
bash "${ROOT_DIR}/tools/cockpit/mistral_agents_tui.sh" --action status
}
run_agents_chat() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/mistral_agents_tui.sh" --action chat
bash "${ROOT_DIR}/tools/cockpit/mistral_agents_tui.sh" --action chat
}
run_agents_health() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/mistral_agents_tui.sh" --action health
bash "${ROOT_DIR}/tools/cockpit/mistral_agents_tui.sh" --action health
}
run_agents_e2e() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/e2e_agents_test.sh" --action all
bash "${ROOT_DIR}/tools/cockpit/e2e_agents_test.sh" --action all
}
# ── Mistral Studio (Lot 24) ──────────────────────────────────────────────
run_studio_status() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --health
bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --health
}
run_studio_files() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --files-list
bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --files-list
}
run_studio_finetune() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --finetune-list
bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --finetune-list
}
run_studio_libraries() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --libraries-list
bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --libraries-list
}
run_studio_conversations() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --conversations-list
bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --conversations-list
}
run_studio_models() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --models-list
bash "${ROOT_DIR}/tools/cockpit/mistral_studio_tui.sh" --models-list
}
run_infra_health() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/infra_container_health.sh" --action status
bash "${ROOT_DIR}/tools/cockpit/infra_container_health.sh" --action status
}
status_view() {
cat <<EOF
# YiACAD operator index
- short_entry:
- bash tools/cockpit/yiacad_operator_index.sh --action status
- execution_continuity:
- kill_life memory latest: ${ROOT_DIR}/artifacts/cockpit/kill_life_memory/latest.md
- operator daily summary: ${ROOT_DIR}/artifacts/cockpit/daily_operator_summary_latest.md
- product contract handoff json: ${ROOT_DIR}/artifacts/cockpit/product_contract_handoff/latest.json
- product contract handoff markdown: ${ROOT_DIR}/artifacts/cockpit/product_contract_handoff/latest.md
- incident watch: bash tools/cockpit/mascarade_incidents_tui.sh --action watch
- routes:
- incident-watch: bash tools/cockpit/mascarade_incidents_tui.sh --action watch
- incident-history: bash tools/cockpit/render_mascarade_watch_history.sh
- uiux: bash tools/cockpit/yiacad_uiux_tui.sh --action status
- global: bash tools/cockpit/yiacad_refonte_tui.sh --action status
- backend: bash tools/cockpit/yiacad_backend_service_tui.sh --action status
- review-context: bash tools/cockpit/yiacad_operator_index.sh --action review-context
- proofs: bash tools/cockpit/yiacad_operator_index.sh --action proofs
- mistral_agents:
- agents-status: bash tools/cockpit/mistral_agents_tui.sh --action status
- agents-chat: bash tools/cockpit/mistral_agents_tui.sh --action chat
- agents-health: bash tools/cockpit/mistral_agents_tui.sh --action health
- agents-e2e: bash tools/cockpit/e2e_agents_test.sh --action all
- mistral_studio:
- studio-status: bash tools/cockpit/mistral_studio_tui.sh --health
- studio-files: bash tools/cockpit/mistral_studio_tui.sh --files-list
- studio-finetune: bash tools/cockpit/mistral_studio_tui.sh --finetune-list
- studio-libraries: bash tools/cockpit/mistral_studio_tui.sh --libraries-list
- studio-conversations: bash tools/cockpit/mistral_studio_tui.sh --conversations-list
- studio-models: bash tools/cockpit/mistral_studio_tui.sh --models-list
- infrastructure:
- infra-health: bash tools/cockpit/infra_container_health.sh --action status
- aliases:
- backend-proof -> backend
- review-session -> yiacad_uiux_tui.sh --action review-session
- review-history -> yiacad_uiux_tui.sh --action review-history
- review-taxonomy -> yiacad_uiux_tui.sh --action review-taxonomy
- review-context -> yiacad_uiux_tui.sh --action review-context
- next_lots:
- T-UX-006
- T-ARCH-101C
- operator_doc:
- docs/YIACAD_OPERATOR_INDEX_2026-03-21.md
- artifacts:
- ${ARTIFACTS_DIR}
EOF
}
proofs_view() {
cat <<EOF
# YiACAD operator proofs
- operator_index:
- docs/YIACAD_OPERATOR_INDEX_2026-03-21.md
- tools/cockpit/yiacad_operator_index.sh
- uiux_lane:
- docs/plans/20_plan_refonte_ui_ux_yiacad_apple_native.md
- docs/plans/20_todo_refonte_ui_ux_yiacad_apple_native.md
- tools/cockpit/yiacad_uiux_tui.sh
- global_lane:
- docs/plans/21_plan_refonte_globale_yiacad.md
- docs/plans/21_todo_refonte_globale_yiacad.md
- tools/cockpit/yiacad_refonte_tui.sh
- global_docs:
- docs/YIACAD_GLOBAL_REFACTOR_AUDIT_2026-03-20.md
- docs/YIACAD_GLOBAL_AI_INTEGRATION_ASSESSMENT_2026-03-20.md
- backend:
- docs/YIACAD_BACKEND_SERVICE_2026-03-21.md
- tools/cockpit/yiacad_backend_service_tui.sh
- proofs:
- docs/YIACAD_OPERATOR_INDEX_2026-03-21.md
- tools/cockpit/yiacad_operator_index.sh --action proofs
- tools/cockpit/mascarade_incidents_tui.sh --action watch
- tools/cockpit/render_mascarade_watch_history.sh
- continuity:
- artifacts/cockpit/kill_life_memory/latest.json
- artifacts/cockpit/kill_life_memory/latest.md
- artifacts/cockpit/daily_operator_summary_latest.md
- artifacts/cockpit/product_contract_handoff/latest.json
- artifacts/cockpit/product_contract_handoff/latest.md
EOF
}
collect_log_files() {
find "${ARTIFACTS_DIR}" -type f 2>/dev/null
}
logs_list() {
local found=0
local path=""
while IFS= read -r path; do
[[ -n "${path}" ]] || continue
[[ "${path}" == "${LOG_FILE:-}" ]] && continue
printf '%s\n' "${path}"
found=1
done < <(collect_log_files | sort)
if [[ "${found}" -eq 0 ]]; then
if [[ -n "${LOG_FILE:-}" ]]; then
printf '%s\n' "${LOG_FILE}"
else
printf 'no logs found\n'
fi
fi
}
logs_latest() {
local latest=""
local path=""
while IFS= read -r path; do
[[ -n "${path}" ]] || continue
[[ "${path}" == "${LOG_FILE:-}" ]] && continue
latest="${path}"
done < <(collect_log_files | sort)
if [[ -z "${latest}" ]]; then
if [[ -n "${LOG_FILE:-}" ]]; then
latest="${LOG_FILE}"
else
printf 'no logs found\n'
return 1
fi
fi
printf '# Latest log\n\n'
printf -- '- path: %s\n' "${latest}"
printf -- '- lines: %s\n\n' "${LINES}"
tail -n "${LINES}" "${latest}"
}
logs_summary() {
local log_count
log_count="$(find "${ARTIFACTS_DIR}" -type f | wc -l | tr -d ' ')"
if [[ "${JSON_MODE}" -eq 1 ]]; then
python3 - <<PY
import json
print(json.dumps({
"status": "done",
"log_files": int("${log_count}"),
"artifacts_dir": "${ARTIFACTS_DIR}",
"entrypoint": "tools/cockpit/yiacad_operator_index.sh",
"uiux_artifacts_dir": "${ROOT_DIR}/artifacts/uiux_tui",
"global_artifacts_dir": "${ROOT_DIR}/artifacts/yiacad_refonte_tui",
"backend_artifacts_dir": "${ROOT_DIR}/artifacts/yiacad_backend_service_tui",
}, ensure_ascii=False))
PY
return 0
fi
cat <<EOF
# YiACAD operator index logs summary
- log files: ${log_count}
- artifacts dir: ${ARTIFACTS_DIR}
- uiux artifacts dir: ${ROOT_DIR}/artifacts/uiux_tui
- global artifacts dir: ${ROOT_DIR}/artifacts/yiacad_refonte_tui
- backend artifacts dir: ${ROOT_DIR}/artifacts/yiacad_backend_service_tui
EOF
}
purge_logs() {
if [[ "${YES}" -ne 1 ]]; then
if command -v gum >/dev/null 2>&1 && have_tty; then
if ! gum confirm "Purger les logs d'index operateur YiACAD de plus de ${DAYS} jours ?"; then
printf 'purge cancelled\n'
return 0
fi
else
printf 'Refusing purge without --yes outside interactive confirm\n' >&2
return 2
fi
fi
find "${ARTIFACTS_DIR}" -type f -mtime +"${DAYS}" -delete
printf 'purged yiacad operator index logs older than %s days in %s\n' "${DAYS}" "${ARTIFACTS_DIR}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--days)
DAYS="${2:-}"
shift 2
;;
--lines)
LINES="${2:-}"
shift 2
;;
--json)
JSON_MODE=1
shift
;;
--yes)
YES=1
shift
;;
--verbose)
VERBOSE=1
shift
;;
--help)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
if [[ -z "${ACTION}" ]]; then
if ACTION="$(choose_action_interactive)"; then
:
else
printf 'Missing --action (interactive selection unavailable)\n' >&2
usage >&2
exit 2
fi
fi
if ! [[ "${DAYS}" =~ ^[0-9]+$ ]]; then
printf -- '--days requires an integer\n' >&2
exit 2
fi
if ! [[ "${LINES}" =~ ^[0-9]+$ ]]; then
printf -- '--lines requires an integer\n' >&2
exit 2
fi
STAMP="$(date +%Y%m%d_%H%M%S)"
LOG_FILE="${ARTIFACTS_DIR}/yiacad_operator_index_${STAMP}.log"
exec > >(tee -a "${LOG_FILE}") 2>&1
printf '[yiacad-operator-index] action=%s timestamp=%s\n' "${ACTION}" "${STAMP}"
case "${ACTION}" in
status)
status_view
;;
incident-watch)
run_incident_watch
;;
incident-history)
run_incident_history
;;
uiux)
run_uiux_status
;;
global)
run_global_status
;;
backend)
run_backend_service
;;
backend-proof)
run_backend_proof
;;
review-session)
run_review_session
;;
review-history)
run_review_history
;;
review-taxonomy)
run_review_taxonomy
;;
review-context)
run_review_context
;;
review-context)
run_review_context
;;
proofs)
proofs_view
;;
agents-status)
run_agents_status
;;
agents-chat)
run_agents_chat
;;
agents-health)
run_agents_health
;;
agents-e2e)
run_agents_e2e
;;
studio-status)
run_studio_status
;;
studio-files)
run_studio_files
;;
studio-finetune)
run_studio_finetune
;;
studio-libraries)
run_studio_libraries
;;
studio-conversations)
run_studio_conversations
;;
studio-models)
run_studio_models
;;
infra-health)
run_infra_health
;;
logs-summary)
logs_summary
;;
logs-list)
logs_list
;;
logs-latest)
logs_latest
;;
purge-logs)
purge_logs
;;
*)
printf 'Unknown action: %s\n' "${ACTION}" >&2
usage >&2
exit 2
;;
esac
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ARTIFACTS_DIR="${ROOT_DIR}/artifacts/yiacad_proofs_tui"
ACTION="status"
DAYS="14"
YES="false"
JSON="false"
STAMP="$(date +%Y%m%d-%H%M%S)"
LOG_FILE=""
usage() {
cat <<'USAGE'
Usage: yiacad_proofs_tui.sh --action <status|backend|review-session|review-history|review-taxonomy|logs-summary|logs-list|logs-latest|purge-logs> [options]
Options:
--action ACTION Action to execute.
--days N Purge logs older than N days. Default: 14.
--json Emit JSON status for machine use.
--yes Confirm destructive actions like purge.
--help Show this help.
USAGE
}
log() {
printf '[yiacad-proofs] %s\n' "$*" | tee -a "${LOG_FILE}" >&2
}
log_cmd() {
log "cmd: $*"
}
ensure_dirs() {
mkdir -p "${ARTIFACTS_DIR}"
LOG_FILE="${ARTIFACTS_DIR}/yiacad_proofs_tui_${STAMP}.log"
touch "${LOG_FILE}"
}
run_backend() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_backend_proof.sh" --action run
bash "${ROOT_DIR}/tools/cockpit/yiacad_backend_proof.sh" --action run
}
run_review_session() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-session
bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-session
}
run_review_history() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-history
bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-history
}
run_review_taxonomy() {
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-taxonomy
bash "${ROOT_DIR}/tools/cockpit/yiacad_uiux_tui.sh" --action review-taxonomy
}
render_status_text() {
cat <<EOF2
YiACAD proofs status
- canonical_entry: bash tools/cockpit/yiacad_proofs_tui.sh --action status
- backend_proof: bash tools/cockpit/yiacad_backend_proof.sh --action run
- review_session: bash tools/cockpit/yiacad_uiux_tui.sh --action review-session
- review_history: bash tools/cockpit/yiacad_uiux_tui.sh --action review-history
- review_taxonomy: bash tools/cockpit/yiacad_uiux_tui.sh --action review-taxonomy
- log_dir: ${ARTIFACTS_DIR}
- note: surface canonique pour les preuves et l hygiene des logs, sans casser les alias historiques
EOF2
}
render_status_json() {
cat <<EOF2
{
"component": "yiacad-proofs-tui",
"timestamp": "${STAMP}",
"canonical_entry": "bash tools/cockpit/yiacad_proofs_tui.sh --action status",
"routes": {
"backend": "bash tools/cockpit/yiacad_backend_proof.sh --action run",
"review_session": "bash tools/cockpit/yiacad_uiux_tui.sh --action review-session",
"review_history": "bash tools/cockpit/yiacad_uiux_tui.sh --action review-history",
"review_taxonomy": "bash tools/cockpit/yiacad_uiux_tui.sh --action review-taxonomy"
},
"log_dir": "${ARTIFACTS_DIR}",
"notes": [
"surface canonique pour les preuves et l hygiene des logs",
"les alias historiques peuvent continuer a pointer vers cette surface plus tard"
]
}
EOF2
}
logs_summary() {
local count latest
count="$(find "${ARTIFACTS_DIR}" -maxdepth 1 -type f -name '*.log' | wc -l | tr -d ' ')"
latest="$(find "${ARTIFACTS_DIR}" -maxdepth 1 -type f -name '*.log' | sort | tail -n 1)"
if [[ "${JSON}" == "true" ]]; then
cat <<EOF2
{
"component": "yiacad-proofs-tui",
"log_count": ${count:-0},
"latest_log": "${latest}"
}
EOF2
else
printf 'YiACAD proofs logs\n'
printf -- '- log_count: %s\n' "${count:-0}"
printf -- '- latest_log: %s\n' "${latest:-none}"
fi
}
logs_list() {
find "${ARTIFACTS_DIR}" -maxdepth 1 -type f -name '*.log' | sort
}
logs_latest() {
local latest
latest="$(find "${ARTIFACTS_DIR}" -maxdepth 1 -type f -name '*.log' | sort | tail -n 1)"
if [[ -n "${latest}" ]]; then
cat "${latest}"
fi
}
purge_logs() {
if [[ "${YES}" != "true" ]]; then
printf 'Refusing to purge logs without --yes\n' >&2
exit 1
fi
find "${ARTIFACTS_DIR}" -maxdepth 1 -type f -name '*.log' -mtime "+${DAYS}" -delete
printf 'Purged logs older than %s days from %s\n' "${DAYS}" "${ARTIFACTS_DIR}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--days)
DAYS="${2:-}"
shift 2
;;
--json)
JSON="true"
shift
;;
--yes)
YES="true"
shift
;;
--help)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 1
;;
esac
done
ensure_dirs
case "${ACTION}" in
status)
if [[ "${JSON}" == "true" ]]; then
render_status_json
else
render_status_text
fi
;;
backend)
run_backend
;;
review-session)
run_review_session
;;
review-history)
run_review_history
;;
review-taxonomy)
run_review_taxonomy
;;
logs-summary)
logs_summary
;;
logs-list)
logs_list
;;
logs-latest)
logs_latest
;;
purge-logs)
purge_logs
;;
*)
printf 'Unknown action: %s\n' "${ACTION}" >&2
usage >&2
exit 1
;;
esac
+284
View File
@@ -0,0 +1,284 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ARTIFACTS_DIR="${ROOT_DIR}/artifacts/yiacad_refonte_tui"
mkdir -p "${ARTIFACTS_DIR}"
ACTION=""
JSON_MODE=0
VERBOSE=0
YES=0
DAYS=14
LINES=80
usage() {
cat <<'EOF'
Usage: yiacad_refonte_tui.sh --action <status|backend-architecture|audit|ai-assessment|feature-map|spec|plan|todo|research|logs-summary|logs-list|logs-latest|purge-logs> [options]
Options:
--action <name> Action to run
--days <N> Retention window for purge-logs (default: 14)
--lines <N> Number of lines for logs-latest (default: 80)
--json Emit JSON for logs-summary
--yes Confirm destructive purge in non-interactive mode
--verbose Print executed commands
--help Show this help
EOF
}
have_tty() {
[[ -t 0 && -t 1 ]]
}
choose_action_interactive() {
if command -v gum >/dev/null 2>&1 && have_tty; then
gum choose \
status \
backend-architecture \
audit \
ai-assessment \
feature-map \
spec \
plan \
todo \
research \
logs-summary \
logs-list \
logs-latest \
purge-logs
return 0
fi
return 1
}
show_file() {
local path="$1"
if [[ -f "${path}" ]]; then
cat "${path}"
else
printf 'missing file: %s\n' "${path}"
return 1
fi
}
status_view() {
cat <<EOF
# YiACAD Global Refonte Status
- operator-index: ${ROOT_DIR}/tools/cockpit/yiacad_operator_index.sh
- audit: ${ROOT_DIR}/docs/YIACAD_GLOBAL_REFACTOR_AUDIT_2026-03-20.md
- ai-assessment: ${ROOT_DIR}/docs/YIACAD_GLOBAL_AI_INTEGRATION_ASSESSMENT_2026-03-20.md
- feature-map: ${ROOT_DIR}/docs/YIACAD_GLOBAL_FEATURE_MAP_2026-03-20.md
- backend-architecture: ${ROOT_DIR}/docs/YIACAD_BACKEND_ARCHITECTURE_2026-03-20.md
- backend-service: ${ROOT_DIR}/docs/YIACAD_BACKEND_SERVICE_2026-03-21.md
- spec: ${ROOT_DIR}/specs/yiacad_global_refonte_spec.md
- plan: ${ROOT_DIR}/docs/plans/21_plan_refonte_globale_yiacad.md
- todo: ${ROOT_DIR}/docs/plans/21_todo_refonte_globale_yiacad.md
- research: ${ROOT_DIR}/docs/YIACAD_GLOBAL_OSS_RESEARCH_2026-03-20.md
- operator-doc: ${ROOT_DIR}/docs/YIACAD_OPERATOR_INDEX_2026-03-21.md
- next lot: T-UX-004 + T-RE-209
EOF
}
collect_log_files() {
find "${ARTIFACTS_DIR}" -type f 2>/dev/null
}
logs_list() {
local found=0
local path=""
while IFS= read -r path; do
[[ -n "${path}" ]] || continue
[[ "${path}" == "${LOG_FILE:-}" ]] && continue
printf '%s\n' "${path}"
found=1
done < <(collect_log_files | sort)
if [[ "${found}" -eq 0 ]]; then
if [[ -n "${LOG_FILE:-}" ]]; then
printf '%s\n' "${LOG_FILE}"
else
printf 'no logs found\n'
fi
fi
}
logs_latest() {
local latest=""
local path=""
while IFS= read -r path; do
[[ -n "${path}" ]] || continue
[[ "${path}" == "${LOG_FILE:-}" ]] && continue
latest="${path}"
done < <(collect_log_files | sort)
if [[ -z "${latest}" ]]; then
if [[ -n "${LOG_FILE:-}" ]]; then
latest="${LOG_FILE}"
else
printf 'no logs found\n'
return 1
fi
fi
printf '# Latest log\n\n'
printf -- '- path: %s\n' "${latest}"
printf -- '- lines: %s\n\n' "${LINES}"
tail -n "${LINES}" "${latest}"
}
logs_summary() {
local log_count
log_count="$(find "${ARTIFACTS_DIR}" -type f | wc -l | tr -d ' ')"
if [[ "${JSON_MODE}" -eq 1 ]]; then
python3 - <<PY
import json
print(json.dumps({
"status": "done",
"log_files": int("${log_count}"),
"artifacts_dir": "${ARTIFACTS_DIR}",
"next_lot": "T-UX-004 + T-RE-209",
}, ensure_ascii=False))
PY
return 0
fi
cat <<EOF
# YiACAD global refonte logs summary
- log files: ${log_count}
- artifacts dir: ${ARTIFACTS_DIR}
- next lot: T-UX-004 + lot operateur YiACAD
EOF
}
purge_logs() {
if [[ "${YES}" -ne 1 ]]; then
if command -v gum >/dev/null 2>&1 && have_tty; then
if ! gum confirm "Purger les logs YiACAD globaux de plus de ${DAYS} jours ?"; then
printf 'purge cancelled\n'
return 0
fi
else
printf 'Refusing purge without --yes outside interactive confirm\n' >&2
return 2
fi
fi
find "${ARTIFACTS_DIR}" -type f -mtime +"${DAYS}" -delete
printf 'purged yiacad refonte logs older than %s days in %s\n' "${DAYS}" "${ARTIFACTS_DIR}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--days)
DAYS="${2:-}"
shift 2
;;
--lines)
LINES="${2:-}"
shift 2
;;
--json)
JSON_MODE=1
shift
;;
--yes)
YES=1
shift
;;
--verbose)
VERBOSE=1
shift
;;
--help)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
if [[ -z "${ACTION}" ]]; then
if ACTION="$(choose_action_interactive)"; then
:
else
printf 'Missing --action (interactive selection unavailable)\n' >&2
usage >&2
exit 2
fi
fi
if ! [[ "${DAYS}" =~ ^[0-9]+$ ]]; then
printf -- '--days requires an integer\n' >&2
exit 2
fi
if ! [[ "${LINES}" =~ ^[0-9]+$ ]]; then
printf -- '--lines requires an integer\n' >&2
exit 2
fi
STAMP="$(date +%Y%m%d_%H%M%S)"
LOG_FILE="${ARTIFACTS_DIR}/yiacad_refonte_tui_${STAMP}.log"
exec > >(tee -a "${LOG_FILE}") 2>&1
printf '[yiacad-refonte-tui] action=%s timestamp=%s\n' "${ACTION}" "${STAMP}"
case "${ACTION}" in
status)
status_view
;;
backend-architecture)
show_file "${ROOT_DIR}/docs/YIACAD_BACKEND_ARCHITECTURE_2026-03-20.md"
;;
audit)
show_file "${ROOT_DIR}/docs/YIACAD_GLOBAL_REFACTOR_AUDIT_2026-03-20.md"
;;
ai-assessment)
show_file "${ROOT_DIR}/docs/YIACAD_GLOBAL_AI_INTEGRATION_ASSESSMENT_2026-03-20.md"
;;
feature-map)
show_file "${ROOT_DIR}/docs/YIACAD_GLOBAL_FEATURE_MAP_2026-03-20.md"
;;
spec)
show_file "${ROOT_DIR}/specs/yiacad_global_refonte_spec.md"
;;
plan)
show_file "${ROOT_DIR}/docs/plans/21_plan_refonte_globale_yiacad.md"
;;
todo)
show_file "${ROOT_DIR}/docs/plans/21_todo_refonte_globale_yiacad.md"
;;
research)
show_file "${ROOT_DIR}/docs/YIACAD_GLOBAL_OSS_RESEARCH_2026-03-20.md"
;;
logs-summary)
logs_summary
;;
logs-list)
logs_list
;;
logs-latest)
logs_latest
;;
purge-logs)
purge_logs
;;
*)
printf 'Unknown action: %s\n' "${ACTION}" >&2
exit 2
;;
esac
+680
View File
@@ -0,0 +1,680 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ARTIFACTS_DIR="${ROOT_DIR}/artifacts/uiux_tui"
mkdir -p "${ARTIFACTS_DIR}"
ACTION=""
JSON_MODE=0
VERBOSE=0
YES=0
DAYS=14
LINES=80
usage() {
cat <<'EOF'
Usage: yiacad_uiux_tui.sh --action <status|lane-status|owners|proofs|backend|backend-proof|review-session|review-history|review-taxonomy|review-context|audit|program-audit|feature-map|next-feature-map|plan|todo|research|next-spec|insertion-points|agent-matrix|logs-summary|logs-list|logs-latest|purge-logs> [options]
Options:
--action <name> Action to run
--days <N> Retention window for purge-logs (default: 14)
--lines <N> Number of lines for logs-latest (default: 80)
--json Emit JSON for status/logs-summary
--yes Confirm destructive purge in non-interactive mode
--verbose Print executed commands
--help Show this help
EOF
}
have_tty() {
[[ -t 0 && -t 1 ]]
}
choose_action_interactive() {
if command -v gum >/dev/null 2>&1 && have_tty; then
gum choose \
status \
lane-status \
owners \
proofs \
backend \
backend-proof \
review-session \
review-history \
review-taxonomy \
review-context \
audit \
program-audit \
feature-map \
next-feature-map \
plan \
todo \
research \
next-spec \
insertion-points \
agent-matrix \
logs-summary \
logs-list \
logs-latest \
purge-logs
return 0
fi
return 1
}
log_cmd() {
if [[ "${VERBOSE}" -eq 1 ]]; then
printf '[cmd] %s\n' "$*"
fi
}
render_backend_status() {
YIACAD_STATUS_JSON="$1" python3 - <<'PY'
import json
import os
payload = json.loads(os.environ["YIACAD_STATUS_JSON"])
service = payload.get("service") or {}
artifacts = payload.get("artifacts") or []
next_steps = payload.get("next_steps") or []
print("# YiACAD Backend Status")
print("")
print(f"- status: {payload.get('status', 'unknown')}")
print(f"- severity: {payload.get('severity', 'unknown')}")
print(f"- action: {payload.get('action', 'unknown')}")
print(f"- execution_mode: {payload.get('execution_mode', 'unknown')}")
print(f"- transport: service-first ({service.get('mode', 'fallback')})")
print(f"- summary: {payload.get('summary', '')}")
details = payload.get("details")
if details:
print(f"- details: {details}")
if artifacts:
print("")
print("## Artifacts")
for artifact in artifacts:
print(f"- {artifact.get('label', 'artifact')}: {artifact.get('path', '')}")
if next_steps:
print("")
print("## Next steps")
for step in next_steps:
print(f"- {step}")
PY
}
run_backend_status() {
local payload=""
local -a backend_cmd=()
local -a native_cmd=()
if [[ -x "${ROOT_DIR}/tools/cad/yiacad_backend_client.py" ]]; then
backend_cmd=(python3 "${ROOT_DIR}/tools/cad/yiacad_backend_client.py" --json-output status)
log_cmd "${backend_cmd[@]}"
if payload="$("${backend_cmd[@]}" 2>/dev/null)"; then
if [[ "${JSON_MODE}" -eq 1 ]]; then
printf '%s\n' "${payload}"
else
render_backend_status "${payload}"
fi
return 0
fi
fi
if [[ -x "${ROOT_DIR}/tools/cad/yiacad_native_ops.py" ]]; then
native_cmd=(python3 "${ROOT_DIR}/tools/cad/yiacad_native_ops.py" status)
if [[ "${JSON_MODE}" -eq 1 ]]; then
native_cmd+=(--json-output)
fi
log_cmd "${native_cmd[@]}"
"${native_cmd[@]}"
return 0
fi
printf 'yiacad_backend_client.py and yiacad_native_ops.py not found\n' >&2
return 1
}
lane_status() {
cat <<EOF
# YiACAD lane status
- active_lots:
- T-UX-004
- T-UX-003
- support_lane:
- Support UI/UX Ops
- next_blocking_lot:
- T-UX-004
- T-UX-003
- blocker_reason:
- le backend YiACAD passe maintenant en service-first jusque dans la TUI UI/UX et la preuve operateur est archivee
- le prochain travail produit est la palette persistante, l inspector contextuel et la remontee vers les points d insertion natifs documentes
- canon_write_set:
- KiCad shell: pcbnew/toolbars_pcb_editor.cpp + eeschema/toolbars_sch_editor.cpp
- KiCad control: board_editor_control.* + sch_editor_control.*
- FreeCAD safe: yiacad_freecad_gui.py
- FreeCAD next shell: MainWindow.cpp
- no_touch:
- common/eda_base_frame.cpp
- src/Gui/DockWindowManager.cpp
- src/Gui/ComboView.cpp
- proofs:
- docs/YIACAD_OPERATOR_INDEX_2026-03-21.md
- docs/YIACAD_BACKEND_SERVICE_2026-03-21.md
- docs/plans/20_plan_refonte_ui_ux_yiacad_apple_native.md
- docs/plans/20_todo_refonte_ui_ux_yiacad_apple_native.md
- docs/plans/21_plan_refonte_globale_yiacad.md
- docs/plans/21_todo_refonte_globale_yiacad.md
- docs/YIACAD_NATIVE_UI_INSERTION_POINTS_2026-03-20.md
- .runtime-home/cad-ai-native-forks/kicad-ki/scripting/plugins/yiacad_kicad_plugin/yiacad_action.py
- .runtime-home/cad-ai-native-forks/freecad-ki/src/Mod/YiACADWorkbench/yiacad_freecad_gui.py
EOF
}
owners_summary() {
cat <<EOF
# YiACAD owners summary
- T-UX-003A: CAD-UX / KiCad-Shell
- T-UX-003B: CAD-UX / FreeCAD-Shell
- T-UX-003C: CAD-UX / KiCad-Native
- T-UX-003D: CAD-UX / FreeCAD-Native
- T-UX-004A: CAD-UX / KiCad-Surface + FreeCAD-Surface
- T-UX-004B: Doc-Research / Mermaid-Map + OSS-Watch
- T-ARCH-101C: CAD-Native + AI-Integration
- Support UI/UX Ops: SyncOps / TUI-Ops + Log-Guard
- matrix:
- docs/AGENT_SPEC_MODULE_MATRIX_2026-03-20.md
- docs/plans/12_plan_gestion_des_agents.md
EOF
}
proofs_summary() {
cat <<EOF
# YiACAD proofs
- plan:
- docs/plans/20_plan_refonte_ui_ux_yiacad_apple_native.md
- todo:
- docs/plans/20_todo_refonte_ui_ux_yiacad_apple_native.md
- output_contract:
- docs/YIACAD_UIUX_OUTPUT_CONTRACT_2026-03-20.md
- specs/contracts/yiacad_uiux_output.schema.json
- specs/contracts/examples/yiacad_uiux_output.example.json
- insertion_points:
- docs/YIACAD_NATIVE_UI_INSERTION_POINTS_2026-03-20.md
- feature_map:
- docs/YIACAD_APPLE_UI_UX_FEATURE_MAP_2026-03-20.md
- research:
- docs/YIACAD_APPLE_UI_UX_OSS_RESEARCH_2026-03-20.md
- agent_matrix:
- docs/AGENT_SPEC_MODULE_MATRIX_2026-03-20.md
- docs/plans/12_plan_gestion_des_agents.md
- global_bundle:
- docs/YIACAD_GLOBAL_REFACTOR_AUDIT_2026-03-20.md
- docs/YIACAD_GLOBAL_AI_INTEGRATION_ASSESSMENT_2026-03-20.md
- docs/plans/21_plan_refonte_globale_yiacad.md
- docs/plans/21_todo_refonte_globale_yiacad.md
- operator_entrypoint:
- docs/YIACAD_OPERATOR_INDEX_2026-03-21.md
- tools/cockpit/yiacad_operator_index.sh
- backend:
- docs/YIACAD_BACKEND_ARCHITECTURE_2026-03-20.md
- docs/YIACAD_BACKEND_SERVICE_2026-03-21.md
- specs/yiacad_backend_architecture_spec.md
- specs/contracts/yiacad_context_broker.schema.json
- specs/contracts/examples/yiacad_context_broker.example.json
- backend_service:
- tools/cockpit/yiacad_backend_service_tui.sh
- backend_proof:
- docs/YIACAD_BACKEND_OPERATOR_PROOF_2026-03-21.md
- tools/cockpit/yiacad_backend_proof.sh
- review_session:
- docs/YIACAD_REVIEW_SESSION_2026-03-21.md
- artifacts/cad-ai-native/latest_review_session.json
- review_history:
- docs/YIACAD_REVIEW_SESSION_2026-03-21.md
- artifacts/cad-ai-native/review_history.json
- review_context:
- docs/YIACAD_REVIEW_SESSION_2026-03-21.md
- tools/cockpit/yiacad_uiux_tui.sh --action review-context
EOF
}
review_session_view() {
local session_file="${ROOT_DIR}/artifacts/cad-ai-native/latest_review_session.json"
if [[ -f "${session_file}" ]]; then
cat "${session_file}"
else
printf 'missing file: %s\n' "${session_file}"
return 1
fi
}
review_history_view() {
local history_file="${ROOT_DIR}/artifacts/cad-ai-native/review_history.json"
if [[ -f "${history_file}" ]]; then
cat "${history_file}"
else
printf 'missing file: %s\n' "${history_file}"
return 1
fi
}
review_taxonomy_view() {
local history_file="${ROOT_DIR}/artifacts/cad-ai-native/review_history.json"
if [[ ! -f "${history_file}" ]]; then
printf 'missing file: %s\n' "${history_file}"
return 1
fi
python3 - <<PY
import json
from collections import Counter
from pathlib import Path
history = json.loads(Path("${history_file}").read_text(encoding="utf-8"))
entries = history.get("entries") or []
taxonomy = Counter((entry.get("taxonomy") or "unknown") for entry in entries)
status = Counter((entry.get("status") or "unknown") for entry in entries)
severity = Counter((entry.get("severity") or "unknown") for entry in entries)
print("# YiACAD review taxonomy")
print()
print(f"- entries: {len(entries)}")
print("- taxonomy:")
for key in sorted(taxonomy):
print(f" - {key}: {taxonomy[key]}")
print("- status:")
for key in sorted(status):
print(f" - {key}: {status[key]}")
print("- severity:")
for key in sorted(severity):
print(f" - {key}: {severity[key]}")
PY
}
review_context_view() {
local session_file="${ROOT_DIR}/artifacts/cad-ai-native/latest_review_session.json"
local history_file="${ROOT_DIR}/artifacts/cad-ai-native/review_history.json"
if [[ ! -f "${session_file}" ]]; then
printf 'missing file: %s\n' "${session_file}"
return 1
fi
if [[ ! -f "${history_file}" ]]; then
printf 'missing file: %s\n' "${history_file}"
return 1
fi
python3 - <<PY
import json
from collections import Counter
from pathlib import Path
session = json.loads(Path("${session_file}").read_text(encoding="utf-8"))
history = json.loads(Path("${history_file}").read_text(encoding="utf-8"))
payload = session.get("payload") if isinstance(session.get("payload"), dict) else session
entries = history.get("entries") or []
taxonomy = Counter((entry.get("taxonomy") or "unknown") for entry in entries)
status = Counter((entry.get("status") or "unknown") for entry in entries)
print("# YiACAD review context")
print()
print(f"- current_action: {payload.get('action') or 'unknown'}")
print(f"- current_status: {payload.get('status') or 'unknown'}")
print(f"- current_severity: {payload.get('severity') or 'unknown'}")
print(f"- current_context: {payload.get('context_ref') or 'no-context'}")
print(f"- current_summary: {(payload.get('summary') or '').strip() or '-'}")
print("- taxonomy:")
for key in sorted(taxonomy):
print(f" - {key}: {taxonomy[key]}")
print("- status_mix:")
for key in sorted(status):
print(f" - {key}: {status[key]}")
print("- recent_trail:")
for entry in entries[:3]:
action = entry.get("action") or "unknown"
entry_status = entry.get("status") or "unknown"
severity = entry.get("severity") or "unknown"
summary = (entry.get("summary") or "").strip() or "-"
print(f" - {action} | {entry_status} | {severity} | {summary}")
print("- next_steps:")
seen = set()
for entry in entries[:5]:
for step in entry.get("next_steps") or []:
if step not in seen:
seen.add(step)
print(f" - {step}")
if not seen:
print(" - none")
PY
}
review_context_view() {
local session_file="${ROOT_DIR}/artifacts/cad-ai-native/latest_review_session.json"
local history_file="${ROOT_DIR}/artifacts/cad-ai-native/review_history.json"
if [[ ! -f "${session_file}" ]]; then
printf 'missing file: %s\n' "${session_file}"
return 1
fi
if [[ ! -f "${history_file}" ]]; then
printf 'missing file: %s\n' "${history_file}"
return 1
fi
python3 - <<PY
import json
from collections import Counter
from pathlib import Path
session = json.loads(Path("${session_file}").read_text(encoding="utf-8"))
history = json.loads(Path("${history_file}").read_text(encoding="utf-8"))
entries = history.get("entries") or []
payload = session.get("payload") if isinstance(session, dict) else {}
if not isinstance(payload, dict) or not payload:
payload = session if isinstance(session, dict) else {}
head = payload
action = str(head.get("action") or "").strip()
if (not action or action == "unknown") and entries:
first = entries[0]
if isinstance(first, dict):
head = first
taxonomy = Counter((entry.get("taxonomy") or "unknown") for entry in entries)
print("# YiACAD review context")
print()
print(f"- command: {session.get('command') or head.get('action') or 'unknown'}")
print(f"- context_ref: {head.get('context_ref') or 'no context'}")
print(f"- status: {head.get('status') or 'unknown'}")
print(f"- severity: {head.get('severity') or 'unknown'}")
summary = str(head.get("summary") or "").strip()
if summary:
print(f"- summary: {summary}")
print("- taxonomy:")
for key in sorted(taxonomy):
print(f" - {key}: {taxonomy[key]}")
print("- recent:")
for entry in entries[:4]:
action = entry.get("action") or "unknown"
status = entry.get("status") or "unknown"
severity = entry.get("severity") or "unknown"
context_ref = entry.get("context_ref") or "no context"
summary = str(entry.get("summary") or "").strip()
generated_at = entry.get("generated_at") or ""
print(f" - {action} | {status} | {severity}")
print(f" context: {context_ref}")
if summary:
print(f" summary: {summary}")
if generated_at:
print(f" generated_at: {generated_at}")
PY
}
show_file() {
local path="$1"
if [[ -f "${path}" ]]; then
cat "${path}"
else
printf 'missing file: %s\n' "${path}"
return 1
fi
}
collect_log_files() {
find "${ARTIFACTS_DIR}" -type f 2>/dev/null
if [[ -d "${ROOT_DIR}/artifacts/cad-ai-native" ]]; then
find "${ROOT_DIR}/artifacts/cad-ai-native" -type f 2>/dev/null
fi
if [[ -d "${ROOT_DIR}/artifacts/yiacad_backend_service_tui" ]]; then
find "${ROOT_DIR}/artifacts/yiacad_backend_service_tui" -type f 2>/dev/null
fi
}
logs_list() {
local found=0
local path=""
while IFS= read -r path; do
[[ -n "${path}" ]] || continue
[[ "${path}" == "${LOG_FILE:-}" ]] && continue
printf '%s\n' "${path}"
found=1
done < <(collect_log_files | sort)
if [[ "${found}" -eq 0 ]]; then
if [[ -n "${LOG_FILE:-}" ]]; then
printf '%s\n' "${LOG_FILE}"
else
printf 'no logs found\n'
fi
fi
}
logs_latest() {
local latest=""
local path=""
while IFS= read -r path; do
[[ -n "${path}" ]] || continue
[[ "${path}" == "${LOG_FILE:-}" ]] && continue
latest="${path}"
done < <(collect_log_files | sort)
if [[ -z "${latest}" ]]; then
if [[ -n "${LOG_FILE:-}" ]]; then
latest="${LOG_FILE}"
else
printf 'no logs found\n'
return 1
fi
fi
printf '# Latest log\n\n'
printf -- '- path: %s\n' "${latest}"
printf -- '- lines: %s\n\n' "${LINES}"
tail -n "${LINES}" "${latest}"
}
logs_summary() {
local uiux_count native_count backend_service_count
uiux_count="$(find "${ARTIFACTS_DIR}" -type f | wc -l | tr -d ' ')"
native_count="0"
backend_service_count="0"
if [[ -d "${ROOT_DIR}/artifacts/cad-ai-native" ]]; then
native_count="$(find "${ROOT_DIR}/artifacts/cad-ai-native" -type f | wc -l | tr -d ' ')"
fi
if [[ -d "${ROOT_DIR}/artifacts/yiacad_backend_service_tui" ]]; then
backend_service_count="$(find "${ROOT_DIR}/artifacts/yiacad_backend_service_tui" -type f | wc -l | tr -d ' ')"
fi
if [[ "${JSON_MODE}" -eq 1 ]]; then
python3 - <<PY
import json
print(json.dumps({
"status": "done",
"uiux_tui_files": int("${uiux_count}"),
"cad_ai_native_files": int("${native_count}"),
"backend_service_files": int("${backend_service_count}"),
"artifacts_dir": "${ARTIFACTS_DIR}",
}, ensure_ascii=False))
PY
return 0
fi
cat <<EOF
# YiACAD UI/UX logs summary
- uiux_tui files: ${uiux_count}
- cad-ai-native files: ${native_count}
- backend service files: ${backend_service_count}
- artifacts dir: ${ARTIFACTS_DIR}
EOF
}
purge_logs() {
if [[ "${YES}" -ne 1 ]]; then
if command -v gum >/dev/null 2>&1 && have_tty; then
if ! gum confirm "Purger les logs UI/UX de plus de ${DAYS} jours ?"; then
printf 'purge cancelled\n'
return 0
fi
else
printf 'Refusing purge without --yes outside interactive confirm\n' >&2
return 2
fi
fi
find "${ARTIFACTS_DIR}" -type f -mtime +"${DAYS}" -delete
printf 'purged uiux logs older than %s days in %s\n' "${DAYS}" "${ARTIFACTS_DIR}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--days)
DAYS="${2:-}"
shift 2
;;
--lines)
LINES="${2:-}"
shift 2
;;
--json)
JSON_MODE=1
shift
;;
--yes)
YES=1
shift
;;
--verbose)
VERBOSE=1
shift
;;
--help)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
if [[ -z "${ACTION}" ]]; then
if ACTION="$(choose_action_interactive)"; then
:
else
printf 'Missing --action (interactive selection unavailable)\n' >&2
usage >&2
exit 2
fi
fi
if ! [[ "${DAYS}" =~ ^[0-9]+$ ]]; then
printf -- '--days requires an integer\n' >&2
exit 2
fi
if ! [[ "${LINES}" =~ ^[0-9]+$ ]]; then
printf -- '--lines requires an integer\n' >&2
exit 2
fi
STAMP="$(date +%Y%m%d_%H%M%S)"
LOG_FILE="${ARTIFACTS_DIR}/yiacad_uiux_tui_${STAMP}.log"
exec > >(tee -a "${LOG_FILE}") 2>&1
if [[ "${JSON_MODE}" -ne 1 ]]; then
printf '[yiacad-uiux-tui] action=%s timestamp=%s\n' "${ACTION}" "${STAMP}"
fi
case "${ACTION}" in
status)
run_backend_status
;;
lane-status)
lane_status
;;
owners)
owners_summary
;;
proofs)
proofs_summary
;;
backend)
show_file "${ROOT_DIR}/docs/YIACAD_BACKEND_SERVICE_2026-03-21.md"
;;
backend-proof)
log_cmd bash "${ROOT_DIR}/tools/cockpit/yiacad_backend_proof.sh" --action run
bash "${ROOT_DIR}/tools/cockpit/yiacad_backend_proof.sh" --action run
;;
review-session)
review_session_view
;;
review-history)
review_history_view
;;
review-taxonomy)
review_taxonomy_view
;;
review-context)
review_context_view
;;
audit)
show_file "${ROOT_DIR}/docs/YIACAD_APPLE_UI_UX_AUDIT_2026-03-20.md"
;;
program-audit)
show_file "${ROOT_DIR}/docs/YIACAD_EXHAUSTIVE_REFOUNTE_AUDIT_2026-03-20.md"
;;
feature-map)
show_file "${ROOT_DIR}/docs/YIACAD_APPLE_UI_UX_FEATURE_MAP_2026-03-20.md"
;;
next-feature-map)
show_file "${ROOT_DIR}/docs/YIACAD_TUX004_FEATURE_MAP_2026-03-20.md"
;;
plan)
show_file "${ROOT_DIR}/docs/plans/20_plan_refonte_ui_ux_yiacad_apple_native.md"
;;
todo)
show_file "${ROOT_DIR}/docs/plans/20_todo_refonte_ui_ux_yiacad_apple_native.md"
;;
research)
show_file "${ROOT_DIR}/docs/YIACAD_APPLE_UI_UX_OSS_RESEARCH_2026-03-20.md"
;;
next-spec)
show_file "${ROOT_DIR}/specs/yiacad_tux004_orchestration_spec.md"
;;
insertion-points)
show_file "${ROOT_DIR}/docs/YIACAD_NATIVE_UI_INSERTION_POINTS_2026-03-20.md"
;;
agent-matrix)
log_cmd bash "${ROOT_DIR}/tools/cockpit/agent_matrix_tui.sh" --action summary
bash "${ROOT_DIR}/tools/cockpit/agent_matrix_tui.sh" --action summary
;;
logs-summary)
logs_summary
;;
logs-list)
logs_list
;;
logs-latest)
logs_latest
;;
purge-logs)
purge_logs
;;
*)
printf 'Unknown action: %s\n' "${ACTION}" >&2
exit 2
;;
esac
+50
View File
@@ -0,0 +1,50 @@
# T-MA-021 — Benchmark Multi-Provider Mascarade
Benchmark comparatif base models vs fine-tuned sur prompts métier électronique.
## Structure
```
evals/
├── benchmark_providers.py # Runner principal (Mistral, Anthropic, OpenAI)
├── prompts/
│ └── metier_100_template.jsonl # 20 prompts template (à étendre à 100)
└── results/ # Résultats des runs (gitignored)
```
## Usage
```bash
# Dry run (pas d'appels API)
python benchmark_providers.py --prompts prompts/metier_100_template.jsonl --dry-run
# Run Mistral uniquement
python benchmark_providers.py --prompts prompts/metier_100_template.jsonl --providers mistral
# Run complet 3 providers
python benchmark_providers.py --prompts prompts/metier_100_template.jsonl --providers mistral,anthropic,openai
# Avec rate limiting adapté au free tier Anthropic (5 RPM)
python benchmark_providers.py --prompts prompts/metier_100_template.jsonl --providers anthropic --rate-limit 13
```
## Format prompts (JSONL)
```json
{"id": "K001", "domain": "kicad", "prompt": "...", "system": "...", "difficulty": "medium"}
```
## Domaines couverts
- **KiCad** (K001-K040): Schéma, PCB, DRC, symboles, empreintes
- **SPICE** (S001-S030): Simulation, netlists, analyse AC/DC/transitoire
- **Embedded** (E001-E020): Firmware STM32/ESP32, drivers, protocoles
- **Mixed** (M001-M010): Architecture hardware+firmware complète
## Env vars
```bash
export MISTRAL_API_KEY="708zM4biF4WjZIAROJh2roFiAPK9O7kG"
export ANTHROPIC_API_KEY="sk-ant-api03-..."
export OPENAI_API_KEY="sk-proj-..."
```
+373
View File
@@ -0,0 +1,373 @@
#!/usr/bin/env python3
"""
T-MA-021 — Benchmark comparatif multi-provider Mascarade
=========================================================
Compare base models vs fine-tuned sur 100 prompts métier (KiCad, SPICE, embedded).
Providers testés:
- Mistral (mistral-small-latest, codestral-latest, + fine-tuned LoRA)
- Anthropic (claude-sonnet-4-20250514, claude-haiku-4-5-20251001)
- OpenAI (gpt-4o, gpt-4o-mini)
Usage:
python benchmark_providers.py --prompts prompts/kicad_100.jsonl --output results/
python benchmark_providers.py --prompts prompts/spice_100.jsonl --output results/ --providers mistral,anthropic
python benchmark_providers.py --prompts prompts/all_100.jsonl --output results/ --dry-run
Env vars requis:
MISTRAL_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY
"""
import argparse
import json
import os
import sys
import time
from dataclasses import dataclass, asdict
from datetime import datetime
from pathlib import Path
from typing import Optional
# --- Provider abstractions ---
@dataclass
class BenchmarkResult:
prompt_id: str
provider: str
model: str
prompt: str
response: str
latency_ms: float
input_tokens: int
output_tokens: int
error: Optional[str] = None
score: Optional[float] = None # filled by evaluator
class ProviderClient:
"""Base class for provider API calls."""
def complete(self, prompt: str, system: str = "", max_tokens: int = 1024) -> dict:
raise NotImplementedError
class MistralClient(ProviderClient):
def __init__(self, model: str = "mistral-small-latest"):
from mistralai import Mistral
self.client = Mistral(api_key=os.environ["MISTRAL_API_KEY"])
self.model = model
self.name = "mistral"
def complete(self, prompt: str, system: str = "", max_tokens: int = 1024) -> dict:
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
t0 = time.time()
resp = self.client.chat.complete(
model=self.model,
messages=messages,
max_tokens=max_tokens,
)
latency = (time.time() - t0) * 1000
choice = resp.choices[0]
return {
"response": choice.message.content,
"latency_ms": latency,
"input_tokens": resp.usage.prompt_tokens,
"output_tokens": resp.usage.completion_tokens,
}
class AnthropicClient(ProviderClient):
def __init__(self, model: str = "claude-sonnet-4-20250514"):
import anthropic
self.client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
self.model = model
self.name = "anthropic"
def complete(self, prompt: str, system: str = "", max_tokens: int = 1024) -> dict:
t0 = time.time()
resp = self.client.messages.create(
model=self.model,
max_tokens=max_tokens,
system=system if system else "You are a helpful electronics engineering assistant.",
messages=[{"role": "user", "content": prompt}],
)
latency = (time.time() - t0) * 1000
return {
"response": resp.content[0].text,
"latency_ms": latency,
"input_tokens": resp.usage.input_tokens,
"output_tokens": resp.usage.output_tokens,
}
class OpenAIClient(ProviderClient):
def __init__(self, model: str = "gpt-4o"):
from openai import OpenAI
self.client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
self.model = model
self.name = "openai"
def complete(self, prompt: str, system: str = "", max_tokens: int = 1024) -> dict:
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
t0 = time.time()
resp = self.client.chat.completions.create(
model=self.model,
messages=messages,
max_tokens=max_tokens,
)
latency = (time.time() - t0) * 1000
choice = resp.choices[0]
return {
"response": choice.message.content,
"latency_ms": latency,
"input_tokens": resp.usage.prompt_tokens,
"output_tokens": resp.usage.completion_tokens,
}
# --- Benchmark runner ---
PROVIDER_CONFIGS = {
"mistral": [
("mistral-small-latest", "base"),
("codestral-latest", "base"),
# ("ft:mistral-small:kicad-v1", "fine-tuned"), # décommenter après fine-tune
],
"anthropic": [
("claude-sonnet-4-20250514", "base"),
("claude-haiku-4-5-20251001", "base"),
],
"openai": [
("gpt-4o", "base"),
("gpt-4o-mini", "base"),
],
}
CLIENT_CLASSES = {
"mistral": MistralClient,
"anthropic": AnthropicClient,
"openai": OpenAIClient,
}
def load_prompts(path: str) -> list[dict]:
"""Load prompts from JSONL. Format: {"id": "P001", "prompt": "...", "system": "...", "expected": "..."}"""
prompts = []
with open(path) as f:
for line in f:
line = line.strip()
if line:
prompts.append(json.loads(line))
return prompts
def run_benchmark(
prompts: list[dict],
providers: list[str],
output_dir: Path,
max_tokens: int = 1024,
dry_run: bool = False,
rate_limit_delay: float = 1.0,
) -> list[BenchmarkResult]:
"""Run all prompts against all provider/model combos."""
results = []
total = sum(len(PROVIDER_CONFIGS.get(p, [])) for p in providers) * len(prompts)
current = 0
for provider_name in providers:
configs = PROVIDER_CONFIGS.get(provider_name, [])
if not configs:
print(f" [SKIP] Provider inconnu: {provider_name}")
continue
for model_id, model_type in configs:
print(f"\n{'='*60}")
print(f" Provider: {provider_name} | Model: {model_id} ({model_type})")
print(f"{'='*60}")
try:
client = CLIENT_CLASSES[provider_name](model=model_id)
except Exception as e:
print(f" [ERROR] Init client: {e}")
continue
for prompt_data in prompts:
current += 1
pid = prompt_data.get("id", f"P{current:03d}")
prompt_text = prompt_data["prompt"]
system_text = prompt_data.get("system", "")
print(f" [{current}/{total}] {pid}...", end=" ", flush=True)
if dry_run:
print("[DRY RUN]")
results.append(BenchmarkResult(
prompt_id=pid, provider=provider_name, model=model_id,
prompt=prompt_text[:100], response="[dry run]",
latency_ms=0, input_tokens=0, output_tokens=0,
))
continue
try:
resp = client.complete(prompt_text, system=system_text, max_tokens=max_tokens)
result = BenchmarkResult(
prompt_id=pid,
provider=provider_name,
model=model_id,
prompt=prompt_text[:200],
response=resp["response"][:500],
latency_ms=round(resp["latency_ms"], 1),
input_tokens=resp["input_tokens"],
output_tokens=resp["output_tokens"],
)
print(f"OK ({resp['latency_ms']:.0f}ms, {resp['output_tokens']} tokens)")
except Exception as e:
result = BenchmarkResult(
prompt_id=pid,
provider=provider_name,
model=model_id,
prompt=prompt_text[:200],
response="",
latency_ms=0,
input_tokens=0,
output_tokens=0,
error=str(e),
)
print(f"ERROR: {e}")
results.append(result)
time.sleep(rate_limit_delay)
return results
def save_results(results: list[BenchmarkResult], output_dir: Path, run_name: str):
"""Save results as JSONL + summary."""
output_dir.mkdir(parents=True, exist_ok=True)
# Raw results
results_file = output_dir / f"{run_name}_results.jsonl"
with open(results_file, "w") as f:
for r in results:
f.write(json.dumps(asdict(r), ensure_ascii=False) + "\n")
print(f"\nResults saved: {results_file}")
# Summary
summary = generate_summary(results)
summary_file = output_dir / f"{run_name}_summary.json"
with open(summary_file, "w") as f:
json.dump(summary, f, indent=2, ensure_ascii=False)
print(f"Summary saved: {summary_file}")
# Print summary
print_summary(summary)
def generate_summary(results: list[BenchmarkResult]) -> dict:
"""Generate benchmark summary with per-model stats."""
from collections import defaultdict
stats = defaultdict(lambda: {
"count": 0, "errors": 0,
"total_latency": 0, "total_input_tokens": 0, "total_output_tokens": 0,
"latencies": [],
})
for r in results:
key = f"{r.provider}/{r.model}"
s = stats[key]
s["count"] += 1
if r.error:
s["errors"] += 1
else:
s["total_latency"] += r.latency_ms
s["total_input_tokens"] += r.input_tokens
s["total_output_tokens"] += r.output_tokens
s["latencies"].append(r.latency_ms)
summary = {
"timestamp": datetime.now().isoformat(),
"total_prompts": len(set(r.prompt_id for r in results)),
"total_calls": len(results),
"models": {},
}
for key, s in stats.items():
success = s["count"] - s["errors"]
latencies = sorted(s["latencies"])
summary["models"][key] = {
"calls": s["count"],
"success": success,
"errors": s["errors"],
"avg_latency_ms": round(s["total_latency"] / max(success, 1), 1),
"p50_latency_ms": round(latencies[len(latencies)//2], 1) if latencies else 0,
"p95_latency_ms": round(latencies[int(len(latencies)*0.95)], 1) if latencies else 0,
"total_input_tokens": s["total_input_tokens"],
"total_output_tokens": s["total_output_tokens"],
"avg_output_tokens": round(s["total_output_tokens"] / max(success, 1), 1),
}
return summary
def print_summary(summary: dict):
"""Pretty-print benchmark summary."""
print(f"\n{'='*80}")
print(f" BENCHMARK SUMMARY — {summary['timestamp']}")
print(f" {summary['total_prompts']} prompts × {len(summary['models'])} models = {summary['total_calls']} calls")
print(f"{'='*80}")
print(f"{'Model':<40} {'OK':>4} {'Err':>4} {'Avg ms':>8} {'P95 ms':>8} {'Avg tok':>8}")
print(f"{'-'*80}")
for model, s in sorted(summary["models"].items()):
print(f"{model:<40} {s['success']:>4} {s['errors']:>4} {s['avg_latency_ms']:>8.0f} {s['p95_latency_ms']:>8.0f} {s['avg_output_tokens']:>8.0f}")
print(f"{'='*80}")
# --- CLI ---
def main():
parser = argparse.ArgumentParser(description="T-MA-021 Benchmark multi-provider Mascarade")
parser.add_argument("--prompts", required=True, help="Path to prompts JSONL file")
parser.add_argument("--output", default="results/", help="Output directory")
parser.add_argument("--providers", default="mistral,anthropic,openai", help="Comma-separated providers")
parser.add_argument("--max-tokens", type=int, default=1024, help="Max output tokens")
parser.add_argument("--rate-limit", type=float, default=1.0, help="Delay between calls (seconds)")
parser.add_argument("--dry-run", action="store_true", help="Don't make actual API calls")
parser.add_argument("--run-name", default=None, help="Run name for output files")
args = parser.parse_args()
providers = [p.strip() for p in args.providers.split(",")]
prompts = load_prompts(args.prompts)
run_name = args.run_name or f"bench_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
print(f"Benchmark: {len(prompts)} prompts × providers {providers}")
print(f"Output: {args.output}/{run_name}_*")
results = run_benchmark(
prompts=prompts,
providers=providers,
output_dir=Path(args.output),
max_tokens=args.max_tokens,
dry_run=args.dry_run,
rate_limit_delay=args.rate_limit,
)
save_results(results, Path(args.output), run_name)
if __name__ == "__main__":
main()
@@ -0,0 +1,20 @@
{"id": "K001", "domain": "kicad", "prompt": "Génère un schéma KiCad (netlist format) pour un circuit de protection ESD avec une TVS bidirectionnelle SMAJ5.0CA sur une ligne USB 2.0.", "system": "Tu es un ingénieur électronique spécialisé en conception de circuits imprimés avec KiCad.", "difficulty": "medium"}
{"id": "K002", "domain": "kicad", "prompt": "Explique comment créer une empreinte custom pour un connecteur FPC 0.5mm pitch 40 broches dans KiCad 8. Donne le code Python pour le scripting.", "system": "Tu es un ingénieur électronique spécialisé en conception de circuits imprimés avec KiCad.", "difficulty": "hard"}
{"id": "K003", "domain": "kicad", "prompt": "Review ce schéma KiCad : un régulateur LDO AMS1117-3.3 alimenté en 5V USB. Identifie les erreurs potentielles de découplage et propose des corrections.", "system": "Tu es un ingénieur électronique spécialisé en conception de circuits imprimés avec KiCad.", "difficulty": "medium"}
{"id": "K004", "domain": "kicad", "prompt": "Génère les règles DRC pour un PCB 4 couches avec impédance contrôlée 50 ohms sur les pistes RF 2.4GHz. Format KiCad DRC rules.", "system": "Tu es un ingénieur électronique spécialisé en conception de circuits imprimés avec KiCad.", "difficulty": "hard"}
{"id": "K005", "domain": "kicad", "prompt": "Crée un symbole KiCad pour un microcontrôleur STM32H743VIT6 avec tous les pins groupés par fonction (GPIO, ADC, SPI, I2C, USB, etc.).", "system": "Tu es un ingénieur électronique spécialisé en conception de circuits imprimés avec KiCad.", "difficulty": "hard"}
{"id": "S001", "domain": "spice", "prompt": "Écris une netlist SPICE pour simuler un filtre passe-bas Butterworth 4ème ordre avec une fréquence de coupure de 1kHz utilisant des ampli-ops TL072.", "system": "Tu es un ingénieur en simulation SPICE et conception analogique.", "difficulty": "medium"}
{"id": "S002", "domain": "spice", "prompt": "Simule en SPICE le comportement transitoire d'un convertisseur buck DC-DC (LM2596) passant de 12V à 5V/3A. Inclus le modèle de l'inductance et du condensateur de sortie.", "system": "Tu es un ingénieur en simulation SPICE et conception analogique.", "difficulty": "hard"}
{"id": "S003", "domain": "spice", "prompt": "Analyse AC d'un amplificateur audio classe AB push-pull avec transistors BD139/BD140. Trace le diagramme de Bode (gain et phase) de 20Hz à 20kHz.", "system": "Tu es un ingénieur en simulation SPICE et conception analogique.", "difficulty": "medium"}
{"id": "S004", "domain": "spice", "prompt": "Modélise en SPICE un circuit de charge de batterie Li-Ion (CC-CV) avec un contrôleur MCP73831. Simule le profil de charge complet.", "system": "Tu es un ingénieur en simulation SPICE et conception analogique.", "difficulty": "hard"}
{"id": "S005", "domain": "spice", "prompt": "Crée un modèle SPICE paramétrique pour une LED RGB WS2812B incluant le comportement I-V et le spectre d'émission simplifié.", "system": "Tu es un ingénieur en simulation SPICE et conception analogique.", "difficulty": "hard"}
{"id": "E001", "domain": "embedded", "prompt": "Écris le driver SPI pour un capteur IMU MPU6050 sur STM32F4 en C. Inclus l'initialisation, la lecture des 6 axes, et le DMA.", "system": "Tu es un développeur firmware embarqué spécialisé STM32 et ESP32.", "difficulty": "medium"}
{"id": "E002", "domain": "embedded", "prompt": "Implémente un protocole de communication custom sur UART entre un ESP32 et un STM32 avec CRC16, framing, et retransmission automatique.", "system": "Tu es un développeur firmware embarqué spécialisé STM32 et ESP32.", "difficulty": "hard"}
{"id": "E003", "domain": "embedded", "prompt": "Écris un driver I2S pour un DAC audio PCM5102A sur ESP32-S3 avec double buffering DMA. Supporte 44.1kHz 16bit stéréo.", "system": "Tu es un développeur firmware embarqué spécialisé STM32 et ESP32.", "difficulty": "hard"}
{"id": "E004", "domain": "embedded", "prompt": "Configure le système de power management d'un ESP32 pour un capteur IoT sur batterie : deep sleep, wake-up timer 5min, RTC memory pour compteur.", "system": "Tu es un développeur firmware embarqué spécialisé STM32 et ESP32.", "difficulty": "medium"}
{"id": "E005", "domain": "embedded", "prompt": "Implémente un bootloader OTA sécurisé pour STM32H7 avec signature ECDSA, rollback automatique, et dual bank flash.", "system": "Tu es un développeur firmware embarqué spécialisé STM32 et ESP32.", "difficulty": "hard"}
{"id": "M001", "domain": "mixed", "prompt": "Conçois l'architecture complète (schéma + firmware) d'un contrôleur LED DMX512 basé sur ESP32 avec 4 univers DMX, interface web, et artnet.", "system": "Tu es un ingénieur électronique full-stack (hardware + firmware).", "difficulty": "hard"}
{"id": "M002", "domain": "mixed", "prompt": "Propose un design de PCB pour une carte d'acquisition audio 4 canaux 24bit/96kHz basée sur un codec CS4270 et un STM32H743.", "system": "Tu es un ingénieur électronique full-stack (hardware + firmware).", "difficulty": "hard"}
{"id": "M003", "domain": "mixed", "prompt": "Analyse les causes possibles de bruit sur un bus I2C longue distance (2m) entre un ESP32 et 8 capteurs de température. Propose des solutions hardware et software.", "system": "Tu es un ingénieur électronique full-stack (hardware + firmware).", "difficulty": "medium"}
{"id": "M004", "domain": "mixed", "prompt": "Conçois un circuit de mesure de courant haute précision (0-30A) avec un shunt + INA226 pour un BMS de batterie Li-Ion 48V. Inclus la protection et le filtrage.", "system": "Tu es un ingénieur électronique full-stack (hardware + firmware).", "difficulty": "medium"}
{"id": "M005", "domain": "mixed", "prompt": "Crée un test bench automatisé en Python pour valider la conformité EMC d'une carte ESP32 : test de rayonnement, immunité, et ESD selon EN 61000.", "system": "Tu es un ingénieur électronique full-stack (hardware + firmware).", "difficulty": "hard"}
+39 -7
View File
@@ -26,13 +26,45 @@ MASCARADE_CORE_DIR = MASCARADE_DIR / "core"
if str(MASCARADE_CORE_DIR) not in sys.path:
sys.path.insert(0, str(MASCARADE_CORE_DIR))
from mascarade.integrations.github_dispatch import ( # noqa: E402
DEFAULT_GITHUB_REPO,
GitHubDispatchAuthError,
GitHubDispatchClient,
GitHubDispatchError,
list_allowlisted_workflows,
)
GITHUB_DISPATCH_IMPORT_ERROR: str | None = None
try:
from mascarade.integrations.github_dispatch import ( # noqa: E402
DEFAULT_GITHUB_REPO,
GitHubDispatchAuthError,
GitHubDispatchClient,
GitHubDispatchError,
list_allowlisted_workflows,
)
except ModuleNotFoundError as exc: # pragma: no cover - exercised through smoke flow
GITHUB_DISPATCH_IMPORT_ERROR = str(exc)
DEFAULT_GITHUB_REPO = os.getenv("GITHUB_DISPATCH_REPO", "electron-rare/Kill_LIFE")
class GitHubDispatchError(RuntimeError):
pass
class GitHubDispatchAuthError(GitHubDispatchError):
pass
class GitHubDispatchClient:
async def dispatch_workflow(self, workflow_file: str, ref: str | None = None, inputs: dict[str, Any] | None = None) -> dict[str, Any]:
raise GitHubDispatchError(
f"Mascarade github_dispatch integration unavailable: {GITHUB_DISPATCH_IMPORT_ERROR}"
)
async def get_dispatch_status(self, dispatch_id: str) -> dict[str, Any]:
raise GitHubDispatchError(
f"Mascarade github_dispatch integration unavailable: {GITHUB_DISPATCH_IMPORT_ERROR}"
)
async def close(self) -> None:
return None
def list_allowlisted_workflows() -> list[str]:
workflows_dir = ROOT / ".github" / "workflows"
if not workflows_dir.is_dir():
return ["repo_state.yml"]
return sorted(path.name for path in workflows_dir.glob("*.yml"))
TOOLS = [
+61 -11
View File
@@ -47,7 +47,22 @@ die() {
exit 1
}
docker_available() {
command -v docker >/dev/null 2>&1 || return 1
if [[ -n "${DOCKER_HOST:-}" ]]; then
if [[ "$DOCKER_HOST" == unix://* ]]; then
local socket_path
socket_path="${DOCKER_HOST#unix://}"
[ -S "$socket_path" ] || return 1
fi
return 0
fi
[ -S "/Users/electron/.docker/run/docker.sock" ] || [ -S "/var/run/docker.sock" ] || [ -S "${HOME}/.docker/run/docker.sock" ] || return 1
}
compose() {
docker_available || die "Docker is required for this operation (compose unavailable)"
debug "docker compose -f $COMPOSE_FILE $*"
docker compose -f "$COMPOSE_FILE" "$@"
}
@@ -102,6 +117,10 @@ resolve_host_openscad() {
ensure_service_up() {
local service="$1"
if ! docker_available; then
return 1
fi
if ! compose ps --status running --services | grep -qx "$service"; then
log "Starting $service"
compose up -d "$service"
@@ -191,25 +210,42 @@ doctor_cmd() {
local host_kicad=""
local host_freecad=""
local host_openscad=""
local host_platformio=""
local degraded=0
host_kicad="$(resolve_host_kicad_cli)"
host_freecad="$(resolve_host_freecad_cmd)"
host_openscad="$(resolve_host_openscad)"
host_platformio="$(command -v platformio 2>/dev/null || true)"
if [ -z "$host_kicad" ]; then
ensure_service_up kicad-headless
if docker_available; then
ensure_service_up kicad-headless
else
log "WARN: kicad-cli non trouvé et docker indisponible, check kicad ignoré"
degraded=1
fi
fi
if [ -z "$host_freecad" ]; then
ensure_service_up freecad-headless
if docker_available; then
ensure_service_up freecad-headless
else
log "WARN: freecadcmd non trouvé et docker indisponible, check freecad ignoré"
degraded=1
fi
fi
if [ -z "$host_openscad" ]; then
ensure_service_up openscad-headless
if docker_available; then
ensure_service_up openscad-headless
else
log "WARN: openscad non trouvé et docker indisponible, check openscad ignoré"
degraded=1
fi
fi
ensure_service_up platformio
if [ -n "$host_kicad" ]; then
"$host_kicad" version
else
elif docker_available; then
run_shell_as_host_user \
kicad-headless \
/workspace/.cad-home/kicad-headless \
@@ -218,7 +254,7 @@ doctor_cmd() {
if [ -n "$host_freecad" ]; then
"$host_freecad" -c 'import FreeCAD; print(".".join(FreeCAD.Version()[:3]))'
else
elif docker_available; then
run_shell_in_service_user \
freecad-headless \
/workspace/.cad-home/freecad-headless \
@@ -227,17 +263,31 @@ doctor_cmd() {
if [ -n "$host_openscad" ]; then
"$host_openscad" --version
else
elif docker_available; then
run_shell_as_host_user \
openscad-headless \
/workspace/.cad-home/openscad-headless \
'mkdir -p "$HOME" && openscad --version'
fi
run_shell_as_host_user \
platformio \
/workspace/.cad-home/platformio \
'mkdir -p "$HOME" "$HOME/.platformio" && export PLATFORMIO_CORE_DIR="$HOME/.platformio" && pio --version'
if [ -n "$host_platformio" ]; then
"$host_platformio" --version
elif docker_available; then
ensure_service_up platformio
run_shell_as_host_user \
platformio \
/workspace/.cad-home/platformio \
'mkdir -p "$HOME" "$HOME/.platformio" && export PLATFORMIO_CORE_DIR="$HOME/.platformio" && pio --version'
else
log "WARN: platformio non trouvé et docker indisponible, check platformio ignoré"
degraded=1
fi
if [ "$degraded" -eq 0 ]; then
log "OK: CAD doctor checks executed successfully."
else
log "DEGRADED: CAD doctor fallback host-only for unavailable container runtime."
fi
}
doctor_mcp_cmd() {
+30 -12
View File
@@ -1,18 +1,36 @@
#!/usr/bin/env python3
"""Very small diff helper for BOM/netlist exports (placeholder)."""
"""Small diff helper for CAD export text artefacts."""
from __future__ import annotations
import difflib
import sys
from pathlib import Path
import difflib
def main():
if len(sys.argv) != 4:
print("usage: hw_diff.py <before> <after> <out.md>", file=sys.stderr)
return 2
before = Path(sys.argv[1]).read_text(encoding="utf-8", errors="ignore").splitlines()
after = Path(sys.argv[2]).read_text(encoding="utf-8", errors="ignore").splitlines()
diff = difflib.unified_diff(before, after, fromfile="before", tofile="after", lineterm="")
Path(sys.argv[3]).write_text("\n".join(diff) + "\n", encoding="utf-8")
return 0
def main() -> int:
if len(sys.argv) != 4:
print(f"usage: {Path(sys.argv[0]).name} <before> <after> <out.md>", file=sys.stderr)
return 2
before_path = Path(sys.argv[1])
after_path = Path(sys.argv[2])
out_path = Path(sys.argv[3])
if not before_path.exists():
print(f"Input file missing: {before_path}", file=sys.stderr)
return 1
if not after_path.exists():
print(f"Input file missing: {after_path}", file=sys.stderr)
return 1
before = before_path.read_text(encoding="utf-8", errors="ignore").splitlines()
after = after_path.read_text(encoding="utf-8", errors="ignore").splitlines()
diff = difflib.unified_diff(before, after, fromfile=before_path.name, tofile=after_path.name, lineterm="")
out_path.write_text("\n".join(diff) + "\n", encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())
raise SystemExit(main())
+9
View File
@@ -64,19 +64,28 @@ def main() -> int:
args = parse_args()
doctor = parse_doctor_output()
host_status = doctor.get('HOST_PCBNEW_IMPORT', 'missing')
entrypoint_state = doctor.get('ENTRYPOINT_STATE', 'missing')
entrypoint = doctor.get('ENTRYPOINT')
payload: dict[str, Any] = {
'status': 'degraded',
'requested_runtime': 'host',
'runtime_mode': 'host',
'quick': args.quick,
'host_pcbnew_import': host_status,
'entrypoint_state': entrypoint_state,
'error': None,
}
if host_status != 'ok':
payload['status'] = 'blocked'
payload['error'] = 'pcbnew not importable on host runtime'
return emit(payload, json_output=args.json)
if entrypoint_state != 'present':
payload['status'] = 'blocked'
payload['error'] = f'host entrypoint missing: {entrypoint or "unknown"}'
return emit(payload, json_output=args.json)
cmd = ['python3', str(BASE_SMOKE), '--runtime', 'host', '--json', '--timeout', str(args.timeout)]
if args.quick:
cmd.append('--quick')
+20 -3
View File
@@ -4,6 +4,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/tools/lib/runtime_home.sh"
COMPOSE_FILE="$ROOT_DIR/deploy/cad/docker-compose.yml"
DOCKER_CLIENT_HOME="${HOME:-}"
VERBOSE=0
DOCTOR=0
RUNTIME="${KICAD_MCP_RUNTIME:-auto}"
@@ -56,7 +57,17 @@ die() {
}
compose() {
docker compose -f "$COMPOSE_FILE" "$@"
if HOME="$DOCKER_CLIENT_HOME" docker compose version >/dev/null 2>&1; then
HOME="$DOCKER_CLIENT_HOME" docker compose -f "$COMPOSE_FILE" "$@"
return
fi
if command -v docker-compose >/dev/null 2>&1; then
HOME="$DOCKER_CLIENT_HOME" docker-compose -f "$COMPOSE_FILE" "$@"
return
fi
die "docker compose support not available"
}
build_pythonpath() {
@@ -193,7 +204,7 @@ export KICAD_PYTHON_FILE_LOG_LEVEL="${KICAD_PYTHON_FILE_LOG_LEVEL:-INFO}"
MASCARADE_DIR="$(
kill_life_resolve_mascarade_dir \
"$ROOT_DIR" \
"finetune/kicad_mcp_server"
"finetune"
)"
SERVER_DIR="$MASCARADE_DIR/finetune/kicad_mcp_server"
ENTRYPOINT="${KICAD_MCP_ENTRYPOINT:-$SERVER_DIR/dist/index.js}"
@@ -209,12 +220,17 @@ DATA_DIR="${KICAD_MCP_DATA_DIR:-$MCP_HOME/data}"
PYTHONPATH_VALUE="$(build_pythonpath)"
PROBE_PYTHON="$(resolve_probe_python)"
HOST_PCBNEW_STATUS="missing"
HOST_ENTRYPOINT_STATE="missing"
CONTAINER_STATUS="unknown"
if probe_pcbnew "$PROBE_PYTHON" "$PYTHONPATH_VALUE"; then
HOST_PCBNEW_STATUS="ok"
fi
if [ -f "$ENTRYPOINT" ]; then
HOST_ENTRYPOINT_STATE="present"
fi
if command -v docker >/dev/null 2>&1 && [ -f "$COMPOSE_FILE" ]; then
CONTAINER_STATUS="available"
else
@@ -223,7 +239,7 @@ fi
case "$RUNTIME" in
auto)
if [ "$HOST_PCBNEW_STATUS" = "ok" ]; then
if [ "$HOST_PCBNEW_STATUS" = "ok" ] && [ "$HOST_ENTRYPOINT_STATE" = "present" ]; then
RUNTIME="host"
else
RUNTIME="container"
@@ -250,6 +266,7 @@ KICAD_MCP_DATA_DIR=$DATA_DIR
PROBE_PYTHON=$PROBE_PYTHON
KICAD_PYTHONPATH=$PYTHONPATH_VALUE
HOST_PCBNEW_IMPORT=$HOST_PCBNEW_STATUS
ENTRYPOINT_STATE=$HOST_ENTRYPOINT_STATE
CONTAINER_STATUS=$CONTAINER_STATUS
KICAD_MCP_IMAGE=$KICAD_MCP_IMAGE
KICAD_MCP_LOG_LEVEL=$KICAD_MCP_LOG_LEVEL
+54 -7
View File
@@ -26,13 +26,40 @@ MASCARADE_CORE_DIR = MASCARADE_DIR / "core"
if str(MASCARADE_CORE_DIR) not in sys.path:
sys.path.insert(0, str(MASCARADE_CORE_DIR))
from mascarade.integrations.knowledge_base import ( # noqa: E402
KnowledgeBaseClient,
knowledge_base_auth_configured,
knowledge_base_provider_label,
knowledge_base_status_detail,
normalized_knowledge_base_provider,
)
KNOWLEDGE_BASE_IMPORT_ERROR: str | None = None
try:
from mascarade.integrations.knowledge_base import ( # noqa: E402
KnowledgeBaseClient,
knowledge_base_auth_configured,
knowledge_base_provider_label,
knowledge_base_status_detail,
normalized_knowledge_base_provider,
)
except ModuleNotFoundError as exc: # pragma: no cover - exercised through smoke flow
KNOWLEDGE_BASE_IMPORT_ERROR = str(exc)
def normalized_knowledge_base_provider() -> str:
return os.getenv("KNOWLEDGE_BASE_PROVIDER", "memos").strip().lower() or "memos"
def knowledge_base_provider_label() -> str:
return normalized_knowledge_base_provider()
def knowledge_base_auth_configured() -> bool:
return False
def knowledge_base_status_detail() -> str:
return (
"Mascarade knowledge-base integration unavailable: "
f"{KNOWLEDGE_BASE_IMPORT_ERROR}"
)
class KnowledgeBaseClient: # pragma: no cover - tool calls return before instantiation
provider = normalized_knowledge_base_provider()
label = knowledge_base_provider_label()
async def close(self) -> None:
return None
TOOLS = [
@@ -108,7 +135,27 @@ def _missing_secret_payload() -> dict[str, Any]:
}
def _integration_unavailable_payload() -> dict[str, Any]:
label = knowledge_base_provider_label()
detail = knowledge_base_status_detail()
return {
"ok": False,
"provider": normalized_knowledge_base_provider(),
"error": {
"code": "integration_unavailable",
"message": detail,
},
"provider_label": label,
}
async def _with_client(callback):
if KNOWLEDGE_BASE_IMPORT_ERROR is not None:
detail = knowledge_base_status_detail()
return error_tool_result(
detail,
_integration_unavailable_payload(),
)
if not knowledge_base_auth_configured():
return error_tool_result(
knowledge_base_status_detail(),
+7 -1
View File
@@ -62,8 +62,14 @@ kill_life_resolve_mascarade_dir() {
fi
candidates+=(
"$root_dir/../mascarade"
"$root_dir/../mascarade-main"
"$root_dir/../mascarade"
"$root_dir/../mascarade-github"
"$root_dir/../Github_Repos/Perso/mascarade-main"
"$root_dir/../Github_Repos/Perso/mascarade"
"$root_dir/../Github_Repos/Perso/mascarade-github"
"$root_dir/../Github_Repos/mascarade-main"
"$root_dir/../Github_Repos/mascarade"
)
for candidate in "${candidates[@]}"; do
@@ -0,0 +1,41 @@
# T-MA-016 — Fine-tune Mistral Small sur dataset KiCad
# Objectif: Spécialiser Mistral Small pour génération/review schémas KiCad
# Dataset: ~15k examples (JSONL instruct format)
# Ref: github.com/mistralai/mistral-finetune
# data
data:
instruct_data: "/data/mascarade-datasets/kicad/kicad_train.jsonl"
data: ""
eval_instruct_data: "/data/mascarade-datasets/kicad/kicad_eval.jsonl"
# model — Mistral Small (utiliser le checkpoint téléchargé)
model_id_or_path: "/models/mistral-small-latest"
lora:
rank: 64
# optim — conservateur pour domaine technique
seq_len: 16384 # KiCad prompts sont longs mais rarement > 16k tokens
batch_size: 1
max_steps: 500 # ~15k samples, ~5 epochs avec packing
optim:
lr: 4.e-5 # légèrement plus bas que default pour données techniques
weight_decay: 0.1
pct_start: 0.05
# other
seed: 42
log_freq: 1
eval_freq: 50
no_eval: False
ckpt_freq: 100
save_adapters: True # LoRA adapters only — merge au déploiement
run_dir: "/runs/kicad_small_finetune"
wandb:
project: "kill-life-finetune"
run_name: "kicad-small-v1"
key: "" # à remplir
offline: False
@@ -0,0 +1,41 @@
# T-MA-017 — Fine-tune Codestral sur dataset SPICE+embedded
# Objectif: Spécialiser Codestral pour génération/review netlist SPICE et code embedded
# Dataset: ~20k examples (JSONL instruct format)
# Ref: github.com/mistralai/mistral-finetune
# data
data:
instruct_data: "/data/mascarade-datasets/spice/spice_embedded_train.jsonl"
data: ""
eval_instruct_data: "/data/mascarade-datasets/spice/spice_embedded_eval.jsonl"
# model — Codestral (code-specialized)
model_id_or_path: "/models/codestral-latest"
lora:
rank: 64
# optim — adapté code/SPICE
seq_len: 16384 # netlists SPICE peuvent être longs
batch_size: 1
max_steps: 600 # ~20k samples, ~5 epochs
optim:
lr: 3.e-5 # plus conservateur pour Codestral (déjà spécialisé code)
weight_decay: 0.1
pct_start: 0.05
# other
seed: 42
log_freq: 1
eval_freq: 50
no_eval: False
ckpt_freq: 100
save_adapters: True
run_dir: "/runs/spice_codestral_finetune"
wandb:
project: "kill-life-finetune"
run_name: "spice-codestral-v1"
key: ""
offline: False

Some files were not shown because too many files have changed in this diff Show More