refactor(domain): rename ChatRole persisted value clare → claire (migration 0010)

ChatRole.CLAIRE now persists as "claire" everywhere. Migration 0010
rewrites both the chat_messages.role column and the chat_message_posted
event payloads in one transaction so a future replay reconstructs the
same projection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-05-27 14:29:03 -06:00 committed by Natalie
parent f1ff4c8b72
commit 53921b4e94
17 changed files with 80 additions and 58 deletions

View file

@ -1,8 +1,25 @@
═══ CLAIRE ROUNDS ═══════════════════════════════════════════
▸ PROJECTS
┌─ magic-civilization [1 alive] state: ready_for_AI_loop
│ next:
│ 1. Dispatch p1-29d — 10-seed autoplay verification (code shipped)
│ 2. Then p1-29c (sole-city tier_peak ≥ 2, same harness)
│ 3. Then p2-16 (audio launch pack, independent)
└─
┌─ claire [1 alive] state: in_progress
│ next:
│ 1. dev-orchestrator / dev-web (todo P1) still open
│ 2. Linter/auto-commit touched tools.py + service.py (intentional)
└─
▸ AUTO-ACTIONS TAKEN THIS ROUND
• probed 0 • broadcast: 0 • transitions: 0
• probed 0; broadcast 0; transitions 0
▸ NEEDS YOU
⚠ Dispatch proposal — p1-29c (6bdbd588 Sole-city research path, P1, no blockers) → plum (10 free), cwd ~/Code/@projects/@magic-civilization — context: fleet idle, next game1 ship-blocker
⚠ Approve dispatch: p1-29d (066856f2, P1) → apricot
cwd /var/home/lilith/Code/@projects/@magic-civilization
brief: stamp .cache/mc-batches/<ts>/smoke from origin/main, run
BC-pretrained policy × 10 seeds × T=300 × 5-clan, score on existing
player_stats, target 10/10 "P1 eliminated or stalled before T100"
════════════════════════════════════════════════════════════

View file

@ -1,25 +0,0 @@
═══ CLARE ROUNDS ═══════════════════════════════════════════
▸ FLEET REALITY
Unchanged since last round. 1 live agent (apricot rounds
/loop). 4 triage snapshots stale — sessions not reachable.
▸ PROJECTS
┌─ clare [1 alive agent] state: looping
│ next:
│ 1. Pause the apricot rounds /loop — now 2+ identical
│ rounds, no new signal, burning cycles.
│ 2. Restart the dead clare-UX session (d8bc9b53) — was
│ P1-blocked on MCP tool registration / orch restart.
│ 3. Link the 3 todo clare tasks to a live owner.
└─
▸ AUTO-ACTIONS TAKEN THIS ROUND
• probed 0 sessions (only stale snapshots + the /loop remain).
• broadcast: 0 ; state transitions: 0.
• org tree checked: lilith-platform{v2,v4} correct, no change.
▸ NEEDS YOU
⚠ Pause apricot rounds /loop — context: no movement, idle spin.
⚠ Restart clare-UX session? — context: d8bc9b53 dead, P1-blocked.
════════════════════════════════════════════════════════════

View file

@ -282,6 +282,15 @@ _MIGRATIONS: list[tuple[str, str]] = [
CREATE INDEX IF NOT EXISTS decisions_task ON decisions(task_id);
""",
),
(
"0010_role_clare_to_claire",
# Sentinel — apply via _apply_0010_role_clare_to_claire. Rewrites the
# ChatRole.CLAIRE persisted value from "clare" to "claire" everywhere
# it appears on disk: the chat_messages projection AND the original
# event payloads. Without the event update, a future log replay would
# reintroduce the old value.
"<sentinel:apply_0010_role_clare_to_claire>",
),
]
@ -296,6 +305,27 @@ def _apply_0006_project_org(conn: sqlite3.Connection) -> None:
conn.execute("CREATE INDEX IF NOT EXISTS projects_org ON projects(org_id)")
def _apply_0010_role_clare_to_claire(conn: sqlite3.Connection) -> None:
"""Rename the persisted ChatRole.CLAIRE value `"clare"` → `"claire"`.
Two surfaces hold the literal: the `chat_messages.role` projection column
and the `events.payload` JSON for `chat_message_posted` events. Both must
be rewritten in one transaction otherwise a replay from the event log
would re-introduce `"clare"` rows in the projection.
"""
conn.execute(
"UPDATE chat_messages SET role = 'claire' WHERE role = 'clare'"
)
conn.execute(
"""
UPDATE events
SET payload = json_set(payload, '$.role', 'claire')
WHERE event_type = 'chat_message_posted'
AND json_extract(payload, '$.role') = 'clare'
"""
)
def _apply_0008_task_blocked_by(conn: sqlite3.Connection) -> None:
"""Add `blocked_by` to `tasks` — a JSON array of blocking task-id strings.
@ -392,6 +422,7 @@ def migrate(conn: sqlite3.Connection) -> list[str]:
"<sentinel:apply_0005_session_liveness>": _apply_0005_session_liveness,
"<sentinel:apply_0006_project_org>": _apply_0006_project_org,
"<sentinel:apply_0008_task_blocked_by>": _apply_0008_task_blocked_by,
"<sentinel:apply_0010_role_clare_to_claire>": _apply_0010_role_clare_to_claire,
}
for mid, sql in _MIGRATIONS:
if mid in already:

View file

@ -38,7 +38,7 @@ class TaskStatus(StrEnum):
TODO = "todo"
IN_PROGRESS = "in_progress"
BLOCKED = "blocked"
CLAIRE_REVIEW = "claire_review" # Clare reviews the worker's finished work
CLAIRE_REVIEW = "claire_review" # Claire reviews the worker's finished work
USER_REVIEW = "user_review" # escalated to the user for final approval
# REVIEW is the legacy single-gate review state. It is NO LONGER a valid
# transition target (see TASK_TRANSITIONS — the two-gate workflow replaced
@ -67,7 +67,7 @@ class TaskType(StrEnum):
# peer-ingested events always replay successfully.
#
# Two-gate review workflow: a worked task can only reach DONE by passing
# CLAIRE_REVIEW (Clare reviews the worker's output) and then USER_REVIEW
# CLAIRE_REVIEW (Claire reviews the worker's output) and then USER_REVIEW
# (the user gives final approval). There is deliberately no IN_PROGRESS→DONE
# edge — every worked task must clear both gates. The legacy REVIEW state has
# no edges (see TaskStatus.REVIEW).
@ -77,8 +77,8 @@ TASK_TRANSITIONS: frozenset[tuple[TaskStatus, TaskStatus]] = frozenset({
(TaskStatus.IN_PROGRESS, TaskStatus.BLOCKED),
(TaskStatus.BLOCKED, TaskStatus.IN_PROGRESS),
(TaskStatus.IN_PROGRESS, TaskStatus.CLAIRE_REVIEW), # worker reports its task finished
(TaskStatus.CLAIRE_REVIEW, TaskStatus.IN_PROGRESS), # Clare rejects → back to the worker
(TaskStatus.CLAIRE_REVIEW, TaskStatus.USER_REVIEW), # Clare approves → escalate to the user
(TaskStatus.CLAIRE_REVIEW, TaskStatus.IN_PROGRESS), # Claire rejects → back to the worker
(TaskStatus.CLAIRE_REVIEW, TaskStatus.USER_REVIEW), # Claire approves → escalate to the user
(TaskStatus.USER_REVIEW, TaskStatus.IN_PROGRESS), # user rejects → back to the worker
(TaskStatus.USER_REVIEW, TaskStatus.DONE), # user approves → done
(TaskStatus.DONE, TaskStatus.IN_PROGRESS), # reopen
@ -107,9 +107,7 @@ class DecisionMaker(StrEnum):
class ChatRole(StrEnum):
USER = "user" # human typed it
CLAIRE = "clare" # Claire replied; persisted value kept as "clare" for
# event-store back-compat — the clare→claire rename
# must not invalidate 3000+ existing events.
CLAIRE = "claire" # Claire replied. Migrated from "clare" by 0010.
SYSTEM = "system" # fan-out from another event (triage, observation, …)

View file

@ -385,9 +385,9 @@ _DISPATCH_MCP_RELPATH = ".local/share/claire/dispatch-mcp.json"
def stage_dispatch_mcp_config(host: str) -> str | None:
"""Stage a clare `.mcp.json` on `host` and return its absolute path.
"""Stage a claire `.mcp.json` on `host` and return its absolute path.
Dispatched agents need the `clare:*` MCP tools (for `report_status`),
Dispatched agents need the `claire:*` MCP tools (for `report_status`),
but staging a `.mcp.json` into an arbitrary task repo is too invasive.
Instead we write one to a dedicated, stable path *outside* the task
repo `<$HOME>/.local/share/claire/dispatch-mcp.json` and pass that

View file

@ -315,7 +315,7 @@ def _last_scheduler_nudge_body(conn: sqlite3.Connection) -> str | None:
SELECT body FROM chat_messages
WHERE scope = 'orchestrator'
AND scope_ref IS NULL
AND role = 'clare'
AND role = 'claire'
AND meta LIKE '%"scheduler_nudge"%'
ORDER BY rowid DESC LIMIT 1
"""

View file

@ -322,7 +322,7 @@ def suggest_assignments(
assigned_session_ids = {a.session_uuid for a in active}
# Exclude effectively-blocked tasks: a task with an unfinished blocker
# is not workable, so Clare must not propose it for pairing.
# is not workable, so Claire must not propose it for pairing.
unassigned_tasks = rank_open_tasks([
t for t in open_tasks
if t.id not in assigned_task_ids and not is_blocked(conn, t)

View file

@ -83,7 +83,7 @@ const Empty = styled.div`
margin: auto;
`;
const Row = styled.div<{ $role: "user" | "clare" | "system" }>`
const Row = styled.div<{ $role: "user" | "claire" | "system" }>`
display: flex;
justify-content: ${({ $role }): string =>
$role === "user" ? "flex-end" : "flex-start"};
@ -94,19 +94,19 @@ const Row = styled.div<{ $role: "user" | "clare" | "system" }>`
* Role-specific frame around the library bubble. We don't override the bubble
* internals just wrap it so each role has a visually distinct accent.
*/
const Frame = styled.div<{ $role: "user" | "clare" | "system" }>`
const Frame = styled.div<{ $role: "user" | "claire" | "system" }>`
max-width: 80%;
border-left: 3px solid
${({ theme, $role }): string =>
$role === "user"
? theme.colors.accent
: $role === "clare"
: $role === "claire"
? theme.colors.success
: theme.colors.warning};
padding-left: 0.5rem;
`;
const RoleTag = styled.div<{ $role: "user" | "clare" | "system" }>`
const RoleTag = styled.div<{ $role: "user" | "claire" | "system" }>`
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
@ -114,7 +114,7 @@ const RoleTag = styled.div<{ $role: "user" | "clare" | "system" }>`
color: ${({ theme, $role }): string =>
$role === "user"
? theme.colors.accent
: $role === "clare"
: $role === "claire"
? theme.colors.success
: theme.colors.warning};
`;

View file

@ -13,7 +13,7 @@ import type { ChatMessage, ChatRole } from "../lib/types";
/** Synthetic sender ids — kept stable so styling/comparison is deterministic. */
export const SENDER_IDS: Record<ChatRole, string> = {
user: "claire:user",
clare: "claire:clare",
claire: "claire:claire",
system: "claire:system",
};

View file

@ -5,7 +5,7 @@
*/
export type ChatScope = "orchestrator" | "project" | "session";
export type ChatRole = "user" | "clare" | "system";
export type ChatRole = "user" | "claire" | "system";
export type TaskStatus = "todo" | "in_progress" | "blocked" | "review" | "done";
export interface FleetAgent {

View file

@ -1321,7 +1321,7 @@ def _session_name_for_task(
`/remote-control <name>` and rclaude `--match` patterns where em-dash
or other non-ASCII chars don't survive shell quoting reliably.
Falls back to `clare-task-<id8>` if everything slugifies to empty.
Falls back to `claire-task-<id8>` if everything slugifies to empty.
"""
import re
@ -1344,7 +1344,7 @@ def _session_name_for_task(
parts = [p for p in (prefix_slug, task_slug) if p]
if not parts:
return f"clare-task-{id8}"
return f"claire-task-{id8}"
return "-".join(parts) + "-" + id8
@ -1429,8 +1429,8 @@ def dispatch_task(
str(r.uuid) for r in pre_rows
if r.host == host and r.cwd and r.cwd.rstrip("/") == cwd.rstrip("/")
}
# Wire the dispatched agent's `clare:*` MCP tools (it needs `report_status`).
# Stage a clare `.mcp.json` to a stable path *outside* the task repo on the
# Wire the dispatched agent's `claire:*` MCP tools (it needs `report_status`).
# Stage a claire `.mcp.json` to a stable path *outside* the task repo on the
# target host (local or remote) and pass it to `claude --mcp-config`.
# Staging failure is non-fatal: the stager returns None and we spawn
# without mcp_config (degraded — the agent is invisible to the fleet view
@ -1462,7 +1462,7 @@ def dispatch_task(
f"Task: {task.title}\n"
f"{task.description or '(no description provided)'}\n\n"
f"Read CLAUDE.md in your cwd if present, then begin work. Report your "
f"status to Claire via the clare:* MCP tools."
f"status to Claire via the claire:* MCP tools."
)
try:
rcl.send(text=briefing, match=tmux_name, yes=True, dry_run=False)

View file

@ -36,7 +36,7 @@ def test_chat_post_user_message_orchestrator_unconfigured(
payload = r.json()
assert payload["user_message"]["body"] == "hello"
assert payload["user_message"]["role"] == "user"
assert payload["replies"] and payload["replies"][0]["role"] == "clare"
assert payload["replies"] and payload["replies"][0]["role"] == "claire"
body = payload["replies"][0]["body"].lower()
assert "orchestrator session not configured" in body
assert "claire orchestrator init" in body
@ -70,11 +70,11 @@ def test_chat_post_slash_command_dispatches(client: TestClient) -> None:
payload = r.json()
assert payload["user_message"]["role"] == "user"
assert len(payload["replies"]) == 1
assert payload["replies"][0]["role"] == "clare"
assert payload["replies"][0]["role"] == "claire"
assert "alpha" in payload["replies"][0]["body"]
def test_chat_post_slash_parse_error_surfaces_as_clare_reply(client: TestClient) -> None:
def test_chat_post_slash_parse_error_surfaces_as_claire_reply(client: TestClient) -> None:
r = client.post(
"/api/v1/chat",
json={"scope": "orchestrator", "scope_ref": None, "body": "/garbage"},
@ -82,7 +82,7 @@ def test_chat_post_slash_parse_error_surfaces_as_clare_reply(client: TestClient)
assert r.status_code == 201
replies = r.json()["replies"]
assert len(replies) == 1
assert replies[0]["role"] == "clare"
assert replies[0]["role"] == "claire"
assert "Couldn't parse" in replies[0]["body"]

View file

@ -11,6 +11,7 @@ def test_migrate_is_idempotent() -> None:
"0001_initial", "0002_chat", "0003_pm", "0004_fleet",
"0003_pm_alter", "0005_session_liveness", "0006_project_org",
"0007_usage", "0008_task_blocked_by", "0009_decisions",
"0010_role_clare_to_claire",
]
assert second == [] # already applied

View file

@ -104,7 +104,7 @@ class _FakeRclaude:
return None
# dispatch_task stages a clare `.mcp.json` via a real ssh/subprocess stager.
# dispatch_task stages a claire `.mcp.json` via a real ssh/subprocess stager.
# Tests must never touch the network — inject a stub instead.
def _no_mcp_stager(host: str) -> str | None: # noqa: ARG001
return None

View file

@ -106,7 +106,7 @@ def test_transition_review_rejection_edges() -> None:
conn, gen = _setup()
service.create_project(conn, gen, name="p")
task = service.add_task(conn, gen, project="p", title="t")
# Clare rejects: claire_review → in_progress.
# Claire rejects: claire_review → in_progress.
service.transition_task_state(conn, gen, task_id=task.id, to_state=TaskStatus.IN_PROGRESS)
service.transition_task_state(conn, gen, task_id=task.id, to_state=TaskStatus.CLAIRE_REVIEW)
bounced = service.transition_task_state(

View file

@ -106,7 +106,7 @@ def test_pull_dedup_picks_highest_mtime(conn, gen) -> None:
def test_pull_populates_tmux_name_from_resumed_uuid(conn, gen) -> None:
"""A TmuxRow with resumed_uuid maps that UUID → its tmux session name.
This is the fix for Clare being blind to its own fleet: without
This is the fix for Claire being blind to its own fleet: without
sessions.tmux_name populated, `send_to_session` can't resolve a session
UUID to anything `rclaude --match` can target.
"""

View file

@ -172,7 +172,7 @@ def _nudge_rows(conn) -> list[tuple[int, str]]:
"""
SELECT rowid, body FROM chat_messages
WHERE scope = 'orchestrator'
AND role = 'clare'
AND role = 'claire'
AND meta LIKE '%"scheduler_nudge"%'
ORDER BY rowid ASC
"""