Files
FabledGrimoire/src/grimoire/routes/family.py
T
bvandeusenandClaude Opus 5.5 0f5ef0be63
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
rename(547): the server is grimoire — package, image, DB defaults, backups
Step 2 of the Scribe -> Grimoire rename.

- src/scribe -> src/grimoire, and every import, patch target, module path,
  hypercorn entry point and `python -m grimoire.restore` with it; pyproject,
  uv.lock and the frontend package are named grimoire.
- Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service
  runs as grimoire / grimoire_test.
- Compose files default the DB user, database and (prod) volume to grimoire,
  each overridable from .env, so an install created as Scribe keeps its data
  by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME.
- Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar.
  Archives named scribe-* with the scribe-backup-tar manifest still restore
  and are still counted at the destination (folder and S3), with tests.
- Exports, the MCP auth realm, the ASGI scope key and the persistent logger
  carry the new name.

Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1",
because every stored secret was derived under it.

Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is
step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:50:45 -04:00

147 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Family canon routes — the web door to the promotion engine.
The agent decides promotions and each project's answers through the MCP
tools; this door exists so a person can READ every decision and its reasons
— the ideas, the adoption matrix, the log — and retire an idea or undo a
decision when they disagree. The promote and propose endpoints are here for
parity with the agent's door, and are recorded as the operator's.
Every write is gated on the idea's note in the service.
"""
import logging
from quart import Blueprint, jsonify, request
from grimoire.auth import get_current_user_id, login_required
from grimoire.routes.utils import not_found
from grimoire.services import family as family_svc
from grimoire.services import family_adoption as adoption_svc
logger = logging.getLogger(__name__)
family_bp = Blueprint("family", __name__, url_prefix="/api/family")
def _page() -> tuple[int, int]:
try:
limit = max(1, min(int(request.args.get("limit", 50)), 200))
offset = max(0, int(request.args.get("offset", 0)))
except ValueError:
limit, offset = 50, 0
return limit, offset
@family_bp.route("/ideas", methods=["GET"])
@login_required
async def list_ideas_route():
limit, offset = _page()
ideas = await family_svc.list_ideas(
get_current_user_id(),
status=request.args.get("status") or None,
platform=request.args.get("platform") or None,
limit=limit, offset=offset,
)
return jsonify({
"ideas": ideas, "criteria": list(family_svc.CRITERIA),
"conflict_order": list(adoption_svc.CONFLICT_ORDER),
})
@family_bp.route("/ideas/<int:note_id>", methods=["GET"])
@login_required
async def get_idea_route(note_id: int):
uid = get_current_user_id()
idea = await family_svc.get_idea(uid, note_id)
if idea is None:
return not_found("Family idea")
idea["precedents"] = await family_svc.precedents(uid, note_id)
return jsonify(idea)
@family_bp.route("/ideas/<int:note_id>/propose", methods=["POST"])
@login_required
async def propose_route(note_id: int):
data = await request.get_json() or {}
try:
idea, created = await family_svc.propose(
get_current_user_id(), note_id, reason=data.get("reason") or "",
applies_when=data.get("applies_when") or None, decided_via="operator",
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify({"idea": idea, "created": created}), (201 if created else 200)
@family_bp.route("/ideas/<int:note_id>/promote", methods=["POST"])
@login_required
async def promote_route(note_id: int):
data = await request.get_json() or {}
try:
result = await family_svc.promote(
get_current_user_id(), note_id,
applies_when=data.get("applies_when") or "",
platforms=data.get("platforms") or [],
criteria=data.get("criteria") or {},
evidence=data.get("evidence") or [],
reason=data.get("reason") or "",
precedent_ids=data.get("precedent_ids") or [],
decided_via="operator",
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(result)
@family_bp.route("/ideas/<int:note_id>/retire", methods=["POST"])
@login_required
async def retire_route(note_id: int):
data = await request.get_json() or {}
try:
result = await family_svc.retire(
get_current_user_id(), note_id, reason=data.get("reason") or "",
decided_via="operator",
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(result)
@family_bp.route("/matrix", methods=["GET"])
@login_required
async def matrix_route():
"""Projects × canon ideas — the adoption matrix. Read-only: the agent
answers through assess_family_adoption; a person reads the answers here
and undoes one from the decision log if they disagree."""
matrix = await adoption_svc.adoption_matrix(
get_current_user_id(),
platform=request.args.get("platform") or None,
project_id=request.args.get("project_id", type=int) or None,
)
return jsonify(matrix)
@family_bp.route("/decisions", methods=["GET"])
@login_required
async def list_decisions_route():
limit, offset = _page()
idea_id = request.args.get("idea_id", type=int)
project_id = request.args.get("project_id", type=int)
rows = await family_svc.list_decisions(
get_current_user_id(), idea_id=idea_id or None, project_id=project_id or None,
limit=limit, offset=offset,
)
return jsonify({"decisions": rows, "limit": limit, "offset": offset})
@family_bp.route("/decisions/<int:decision_id>/undo", methods=["POST"])
@login_required
async def undo_route(decision_id: int):
data = await request.get_json() or {}
try:
result = await family_svc.undo(
get_current_user_id(), decision_id, reason=data.get("reason") or "",
decided_via="operator",
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(result)