Skip to content

feat(persona): persona assignment system with H-4 pure hook separation#41

Open
frits-v wants to merge 11 commits intomainfrom
wt/e92da5ee
Open

feat(persona): persona assignment system with H-4 pure hook separation#41
frits-v wants to merge 11 commits intomainfrom
wt/e92da5ee

Conversation

@frits-v
Copy link
Copy Markdown
Owner

@frits-v frits-v commented Mar 25, 2026

Summary

  • New persona/ crate (muzzle-persona) — persistent named identities for Claude Code agents with SQLite storage, affinity-based scoring, and auto-grow
  • New persona-inject PreToolUse hook binary — separate from permissions, preserving H-4 purity. Must be registered FIRST in the hook chain (Claude Code bug #15897)
  • permissions.rs and output.rs are completely untouched — zero persona code in the security-critical path
  • Broker assigns personas via role matching (affinity + expertise overlap - recency penalty) in a single BEGIN IMMEDIATE transaction
  • 14 CLI subcommands: assign, release, list, show, history, feedback, search, retire, reactivate, grow, compact, stats, seed, orphan-cleanup
  • 29 persona tests (27 unit + 2 integration) + 7 persona-inject hook tests, all passing

Architecture

Claude Code → PreToolUse hooks (ordered):
  1. persona-inject  ← NEW: detects Agent+name, shells out to muzzle-persona,
                        returns updatedInput with persona preamble (fail-open)
  2. permissions     ← UNCHANGED: sandbox + git safety (H-4 pure, no writes)

Key: persona-inject has its own local JSON response struct with updatedInput. The shared output.rs and Decision enum are untouched.

Design docs

  • Spec: .agents/superpowers/specs/2026-03-21-muzzle-persona-system-design.md
  • PoC findings: .agents/research/2026-03-23-persona-poc-findings.md
  • Plan: docs/superpowers/plans/2026-03-24-muzzle-persona-v2.md

Test plan

  • cargo test --workspace — all tests pass (including CLAUDE.md integration tests)
  • cargo clippy --workspace -- -D warnings — clean
  • Verify permissions.rs has zero diff from main (H-4 preserved)
  • Verify output.rs has zero diff from main
  • Manual: muzzle-persona assign --roles='["code-reviewer"]' --project=test --session=s1 --agent-name=w1

🤖 Generated with Claude Code

frits-v added 8 commits March 25, 2026 08:27
…ona-inject

--roles arg must be valid JSON array (e.g. ["general"]), not [general].
Output from muzzle-persona assign is {"assignments":[...],"preambles":[...]}
— extract preamble from output["preambles"][0], not assignments[0]["preamble"].
…ts and docs

- session_end.rs: shell out to muzzle-persona release on session cleanup
- Makefile: add persona-inject and muzzle-persona to install/deploy/sizes
- CLAUDE.md: document persona crate, persona-inject binary, H-4 separation,
  hook ordering requirement (bug #15897)
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 25, 2026

📝 Walkthrough

Walkthrough

Adds a new muzzle-persona crate with CLI and SQLite-backed persona management, a persona-inject PreToolUse hook that prepends persona preambles to Agent prompts (fail‑open), adjusts session-end to release persona locks, updates workspace/build targets, and documents hook ordering and commit scopes.

Changes

Cohort / File(s) Summary
Workspace & Build
Cargo.toml, Makefile
Workspace adds persona/; install, deploy, and sizes targets updated to include muzzle-persona and persona-inject binaries and persona source files.
Documentation / Commit Scopes
CLAUDE.md
Documents new persona crate, adds persona-inject PreToolUse hook, panic policy carve‑out (persona-inject is fail‑open), explicit registration ordering requirement (persona-inject before permissions), new commit scopes (persona, broker, preamble, broker, preamble, preamble noted), and increments test count.
Hook Declarations
hooks/Cargo.toml
Adds [[bin]] entry for persona-inject -> src/bin/persona_inject.rs.
Hook Implementations
hooks/src/bin/persona_inject.rs, hooks/src/bin/session_end.rs
New persona_inject binary: reads JSON stdin, eligibility check (Agent + name/team), calls muzzle-persona assign, extracts preamble and prepends to prompt, returns allow with updated_input; errors/panics are fail‑open (silent passthrough). session_end now best-effort runs muzzle-persona release --session=<id> and logs failures.
Persona Crate Manifest & Root
persona/Cargo.toml, persona/src/lib.rs, persona/src/main.rs
New crate muzzle-persona with CLI (muzzle-persona) supporting 14 subcommands (assign, release, list, show, history, feedback, search, retire, reactivate, grow, compact, stats, seed, orphan-cleanup) operating on ~/.muzzle/memory.db.
Persona Types & Schema
persona/src/types.rs, persona/src/schema.rs
Adds role vocabulary and normalization, serde types (Persona, Assignment, FeedbackEntry, PersonaStatus), and SQLite schema creation (tables personas, persona_feedback, persona_assignments) plus indexes.
Persona Core Logic
persona/src/broker.rs, persona/src/release.rs, persona/src/grow.rs, persona/src/preamble.rs
Broker assigns and locks personas by affinity/expertise with deterministic tie-breaks and optional summon; release recomputes affinities from feedback, clears locks, archives stale personas; grow auto-generates personas from seed vocabularies; preamble formats and Unicode-truncates preambles (max 500 chars).
Seed & Data Files
persona/src/seed.rs, persona/personas-seed.toml
TOML seed parsing and idempotent insertion (insert_seed), timestamp helper now_iso8601, and personas-seed.toml with vocabularies, names, personas, and role_instructions.
Tests & Integration
persona/tests/integration_test.rs
Integration tests covering assign→preamble→release lifecycle and auto-grow when pool exhausted.

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant Hook as "persona-inject\nHook"
    participant CLI as "muzzle-persona\nCLI"
    participant DB as "SQLite\nmemory.db"
    participant Agent as "Agent\nTool"

    Client->>Hook: JSON hook request (stdin)
    activate Hook
    Hook->>Hook: Parse input & eligibility check
    alt eligible (Agent, has name/team)
        Hook->>CLI: run `muzzle-persona assign --roles --project --session ...`
        activate CLI
        CLI->>DB: ensure schema / load active personas
        CLI->>DB: score & select persona(s)
        CLI->>DB: lock persona(s) to session & insert assignment(s)
        CLI-->>Hook: JSON with preambles (stdout)
        deactivate CLI

        Hook->>Hook: extract first preamble
        Hook->>Agent: return HookResponse allow + updated_input (prompt prefixed)
    else not eligible or errors
        Hook->>Agent: return HookResponse allow (pass-through)
    end
    deactivate Hook

    Note over Hook,DB: At session end, `session_end` runs:\n`muzzle-persona release --session=<id>` to clear locks and recompute affinity
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: adding a persona assignment system as a new feature with explicit emphasis on H-4 purity preservation through hook separation.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, covering the new persona crate, persona-inject hook, architecture decisions, CLI commands, and test coverage.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch wt/e92da5ee

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 25, 2026

Greptile Summary

This PR introduces the muzzle-persona crate — a persistent named-identity system for Claude Code agents backed by SQLite, with affinity-based assignment scoring and auto-grow — plus a new persona-inject PreToolUse hook binary that keeps the existing permissions binary untouched (H-4 purity preserved). The architecture is clean and well-tested (36 new tests), but there are four issues worth resolving before merging:

  • make deploy skips new binaries (Makefile:47): the deploy target's binary copy loop was not updated, so persona-inject and muzzle-persona will be absent from any production deploy.
  • No subprocess timeout in persona-inject (persona_inject.rs:169): Command::output() blocks indefinitely; if muzzle-persona assign stalls under SQLite WAL pressure the hook will hang every eligible Agent call.
  • recompute_affinity ignores compacted flag (release.rs:75): the feedback query has no WHERE compacted = 0 predicate, so the compact subcommand has no effect on affinity scoring — old feedback is counted forever regardless of compaction.
  • ISO 8601 T/Z vs SQLite datetime() in auto-retire (release.rs:196): the same text-ordering issue flagged for orphan-cleanup in main.rs also affects the auto-retire step in release; datetime(last_assigned) is the minimal fix.

Confidence Score: 2/5

  • Not safe to merge — the deploy Makefile bug means the feature won't work after a production deploy, and two independent datetime comparison bugs cause silent data-correctness failures.
  • Four concrete bugs: a deploy pipeline gap (new binaries never copied by make deploy), an unbounded subprocess blocking issue, a no-op compaction feature (affinity scoring is unaffected by compact), and a repeated ISO 8601 vs SQLite datetime comparison bug in release.rs that prevents personas from ever being auto-retired. None of these are security regressions, but two are silent data-correctness failures and one breaks the deploy pipeline entirely.
  • Makefile (deploy loop), persona/src/release.rs (two separate issues), and hooks/src/bin/persona_inject.rs (timeout).

Important Files Changed

Filename Overview
hooks/src/bin/persona_inject.rs New PreToolUse hook that shells out to muzzle-persona assign and prepends a persona preamble to the Agent prompt. Correctly fail-open and H-4 pure, but cmd.output() has no timeout — a stalled subprocess will block all eligible Agent calls indefinitely.
persona/src/release.rs Session-end release logic with affinity recomputation and auto-retire. Two issues: (1) recompute_affinity doesn't filter compacted = 0, so the compact command has no effect on scoring; (2) auto-retire step compares ISO 8601 T/Z timestamps against datetime() output using text ordering, causing eligible personas to never be archived.
Makefile The install target was updated for the new binaries, but the deploy target's binary copy loop was not — persona-inject and muzzle-persona will be absent from production deploys.
persona/src/broker.rs Affinity-scored persona assignment broker with BEGIN IMMEDIATE serialisation, summon support, and auto-grow fallback. Logic is sound; the summon lock-clearing behaviour was intentional per spec (discussed in previous thread).
persona/src/main.rs 14-subcommand CLI. SQL parameterisation issue (previously flagged) was fixed in d85f018. The run_orphan_cleanup ISO 8601 comparison issue was flagged separately; same root cause also present in release.rs.
persona/src/grow.rs xorshift64 PRNG-based persona auto-grow with weighted expertise selection. RNG seed fix (as_nanos()) was applied in d85f018; logic is correct.
persona/src/schema.rs Clean three-table schema with five indexes. Idempotent via CREATE TABLE IF NOT EXISTS. No issues found.
persona/src/seed.rs TOML seed parsing and idempotent INSERT OR IGNORE loading. Howard Hinnant civil-calendar implementation for now_iso8601() is correct. No issues found.
persona/src/preamble.rs Persona preamble formatter with 500-char budget enforcement and UTF-8-safe truncation. Logic is correct and well-tested.
hooks/src/bin/session_end.rs Adds best-effort muzzle-persona release call on session end. Correctly guards on binary existence and logs but does not fail on error.

Sequence Diagram

sequenceDiagram
    participant CC as Claude Code
    participant PI as persona-inject<br/>(PreToolUse #1)
    participant PM as muzzle-persona<br/>assign
    participant DB as SQLite<br/>(memory.db)
    participant PE as permissions<br/>(PreToolUse #2)

    CC->>PI: PreToolUse {tool_name:"Agent", tool_input:{name:"worker-1", prompt:"..."}}
    PI->>PI: is_persona_eligible? (Agent + name/team_name)
    PI->>PI: infer_role(tool_input)
    PI->>PM: spawn: assign --roles=[...] --project=P --session=S --agent-name=worker-1
    PM->>DB: BEGIN IMMEDIATE
    PM->>DB: load active, unlocked candidates
    DB-->>PM: candidate pool
    PM->>PM: score(affinity + expertise - recency_penalty)
    PM->>DB: UPDATE personas SET assigned_to_session=S
    PM->>DB: INSERT persona_assignments
    PM->>DB: COMMIT
    PM-->>PI: {assignments:[...], preambles:["You are Alice..."]}
    PI-->>CC: {hookSpecificOutput:{permissionDecision:"allow", updatedInput:{prompt:"You are Alice...\n\n..."}}}
    CC->>PE: PreToolUse (with updated prompt)
    PE-->>CC: {permissionDecision:"allow"}
    CC->>CC: Execute Agent with persona preamble injected

    Note over CC,PE: On SessionEnd
    CC->>PM: release --session=S
    PM->>DB: BEGIN IMMEDIATE
    PM->>DB: increment role_counts, recompute affinity
    PM->>DB: clear assigned_to_session, stamp released_at
    PM->>DB: COMMIT
Loading

Reviews (3): Last reviewed commit: "fix(persona): use repeat_n instead of re..." | Re-trigger Greptile

Comment on lines +287 to +298
if status == "archived" {
conn.execute(
"UPDATE personas SET status = 'active', assigned_to_session = NULL WHERE id = ?1",
params![id],
)?;
} else {
// Clear stale lock from a previous session.
conn.execute(
"UPDATE personas SET assigned_to_session = NULL WHERE id = ?1",
params![id],
)?;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Summon unconditionally steals live session locks

The comment says "Clear stale lock from a previous session", but the UPDATE has no predicate that verifies the lock is actually stale. If persona "Bob Jones" is locked to a live session A (assigned_to_session = 'sess-A') and session B calls assign with summon = Some("Bob Jones"), this code will silently clear session A's lock and reassign the persona — breaking the mutual-exclusivity guarantee that the entire broker design relies on.

The fix is to only clear the lock when the persona is not currently held by a live session (e.g., by checking a timeout, or by not clearing at all and instead returning None to fall back to normal scoring):

// Only clear the lock if it's genuinely stale (no live session holds it).
// For now, fail gracefully: if the persona is already assigned, do not summon it.
if status != "archived" && row.assigned_to_session.is_some() {
    return Ok(None); // fall back to normal pool selection
}

Alternatively, document that summon intentionally overrides live locks, but then callers need to be aware of this semantics.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The entire assign runs inside BEGIN IMMEDIATE, serializing access. Two sessions cannot race on the same lock. Clearing a stale lock during summon is intentional per spec.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Makefile (1)

47-50: ⚠️ Potential issue | 🔴 Critical

Deploy never copies the new persona binaries.

install includes persona-inject and muzzle-persona, but this deploy loop still ships only the old binaries. A deployed setup will not be able to register persona-inject, and hooks/src/bin/session_end.rs will not find muzzle-persona for release cleanup.

🛠️ Proposed fix
-	`@for` b in session-start permissions changelog session-end ensure-worktree memory; do \
+	`@for` b in session-start permissions changelog session-end ensure-worktree memory persona-inject muzzle-persona; do \
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Makefile` around lines 47 - 50, The deploy loop in the Makefile copies only
binaries listed in the for-loop (session-start permissions changelog session-end
ensure-worktree memory) and omits the new persona binaries, so deployed installs
lack persona-inject and muzzle-persona; update the loop or copy list to include
persona-inject and muzzle-persona (or add a separate cp for those names) so that
persona-inject and muzzle-persona from target/release are copied into
$(DEPLOY_TARGET)/bin/, ensuring the deploy step ships the new persona binaries
referenced by hooks/src/bin/session_end.rs and registration code.
🧹 Nitpick comments (3)
persona/src/grow.rs (1)

16-24: PRNG seeding from subsec_nanos may produce collisions under rapid calls.

from_time() uses subsec_nanos() which has nanosecond resolution but on some systems may only advance at millisecond granularity. Rapid sequential calls (e.g., broker retrying in a tight loop) could receive identical seeds, producing identical name sequences.

This could cause multiple grow attempts to fail uniqueness checks simultaneously if they generate the same candidate names.

Consider incorporating additional entropy (e.g., process ID, a counter, or the full as_secs() value mixed in):

🛡️ Proposed improvement
     pub fn from_time() -> Self {
-        let nanos = SystemTime::now()
+        let dur = SystemTime::now()
             .duration_since(SystemTime::UNIX_EPOCH)
-            .unwrap_or_default()
-            .subsec_nanos();
-        Rng((nanos as u64) | 1)
+            .unwrap_or_default();
+        // Mix seconds and nanos for better entropy across rapid calls
+        let seed = dur.as_secs() ^ (dur.subsec_nanos() as u64);
+        Rng(seed | 1)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@persona/src/grow.rs` around lines 16 - 24, from_time() seeds Rng using only
subsec_nanos(), which can collide under rapid calls; update Rng::from_time to
mix in additional entropy (e.g., SystemTime::now().as_secs(), process id via
std::process::id(), and/or a small atomic counter) into the seed before
constructing Rng so consecutive calls produce different u64 seeds; combine
values with XOR/rotations or a simple mixing function and return Rng(seed) from
from_time() to avoid identical name sequences.
CLAUDE.md (1)

56-66: Add language specifier to fenced code block.

The static analysis tool flagged this code block as missing a language specifier. For consistency with the other source layout blocks in this file (Lines 20-42, 46-52), add a language identifier.

📝 Proposed fix
-```
+```text
 lib.rs              # Library root
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLAUDE.md` around lines 56 - 66, The fenced code block showing repository
files is missing a language specifier; update the triple-backtick fence that
precedes the list (the block containing lib.rs, types.rs, schema.rs, etc.) to
include a language identifier (use "text" to match other source layout blocks)
so the block becomes ```text and remains closed with ```.
persona/src/release.rs (1)

181-189: Potential date format mismatch in archival query.

The archival query uses SQLite's datetime('now', '-30 days') which produces YYYY-MM-DD HH:MM:SS format (no T separator, no Z suffix), while last_assigned is stored using now_iso8601() which produces YYYY-MM-DDTHH:MM:SSZ.

SQLite's string comparison will still work correctly since ISO-8601 is lexicographically sortable, but consider using consistent formatting for clarity:

-- Current format from now_iso8601(): 2026-03-25T15:30:00Z
-- datetime() produces:               2026-03-25 15:30:00

The comparison last_assigned < datetime('now', '-30 days') should still yield correct results due to lexicographic ordering, but this is worth noting for future maintainers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@persona/src/release.rs` around lines 181 - 189, The UPDATE uses
datetime('now', '-30 days') which yields "YYYY-MM-DD HH:MM:SS" while
last_assigned is stored via now_iso8601() ("YYYY-MM-DDTHH:MM:SSZ"); change the
right-hand expression so both timestamps use the same ISO8601 format—e.g.
replace datetime('now', '-30 days') in the SQL executed by conn.execute(...)
with strftime('%Y-%m-%dT%H:%M:%SZ','now','-30 days') so the comparison against
last_assigned remains consistent and unambiguous.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@hooks/src/bin/persona_inject.rs`:
- Around line 83-85: The code is hand-building the JSON payload for the --roles
argument from arbitrary text returned by
infer_role/tool_input.get("subagent_type"), which breaks if the role contains
quotes or backslashes; update the places where you currently construct roles
like format!("[\"{}\"]", role) (the branch using tool_input.get("subagent_type")
and the later block around the current --roles construction) to instead create a
Rust Vec or slice containing the role and call
serde_json::to_string(&vec![role_str]) (or to_string(&[role_str])). Use the
resulting serialized string as the --roles value so the role is properly
quoted/escaped.
- Around line 157-168: The persona subprocess is invoked with cmd.output().ok()?
which can block indefinitely; change to spawn the process (use cmd.spawn()),
wait for completion with a bounded timeout (use std::time::Duration and a
wait-with-timeout strategy such as the wait-timeout crate or a manual loop), and
if the timeout elapses kill the child and return an error or fallback value
instead of blocking; update uses of persona_bin, cmd, and output handling to use
Child from cmd.spawn(), enforce the timeout, and handle kill/cleanup and error
logging before returning.
- Around line 39-43: The current main uses let _ =
std::panic::catch_unwind(run); which swallows panics and causes implicit allow;
implement a print_deny(reason: &str) function (using HookResponse and
HookSpecificOutput as shown) that serializes to JSON and prints it, then change
main to match on std::panic::catch_unwind(run) and on Err(e) call print_deny
with a concise reason (e.g., panic message or "panic in run") before exiting;
ensure serde_json::to_string is used and HookResponse/HookSpecificOutput field
names exactly match existing types so any panic path emits a deny response
instead of failing open.

In `@hooks/src/bin/session_end.rs`:
- Around line 66-72: The code currently swallows the result of running the
persona unlock command (persona_bin and the Command invoking "release" with
"--session=..."); change it to capture the Result from
Command::new(...).output(), handle Err by logging the error, and on Ok(output)
check output.status.success() and log output.stderr (and status code) when
non-zero so failures are visible; keep it best-effort (don’t panic) but ensure
failures are recorded (use existing logger or eprintln!/tracing::error!)
referencing persona_bin, the "release" args, and sess.id to provide context.

In `@persona/Cargo.toml`:
- Around line 8-12: Remove the disallowed dependencies by deleting the rusqlite
and toml entries from the persona crate manifest (remove the lines for rusqlite
= { version = "0.32", features = ["bundled"] } and toml = "0.8" in the
[dependencies] section) so the crate only depends on the allowed packages
(serde, serde_json, regex, flate2, libc); if any code references rusqlite or
toml (search for usages of rusqlite:: and toml:: or functions that parse TOML),
replace those paths with permitted alternatives or refactor to avoid those
crates before committing the manifest change.

In `@persona/src/broker.rs`:
- Around line 85-106: The score function in persona/src/broker.rs is failing
rustfmt checks; run cargo fmt (or rustfmt) to reformat the file so the
score(candidate: &Candidate, role: &str) function matches project style.
Specifically, reformat the block that constructs affinity, required via
expertise_for_role(role), expertise_overlap calculation over
candidate.expertise, and the final expression using
recency_penalty(&candidate.last_assigned) so CI formatting passes.
- Around line 143-157: pick_or_grow currently ignores the numeric result from
grow::grow and proceeds as if at least one persona was inserted; capture the
return value (e.g., let inserted = grow::grow(...)?), and if inserted == 0
return a clear error instead of continuing to reload an empty pool—either map to
a descriptive error (or use anyhow::anyhow / a crate-appropriate error type)
indicating name exhaustion from grow::grow, so callers of pick_or_grow (and
functions like pick_best and load_candidates) get a meaningful failure instead
of QueryReturnedNoRows.

In `@persona/src/grow.rs`:
- Around line 123-127: The function signature for grow in persona/src/grow.rs is
misformatted; run rustfmt (cargo fmt) to reflow the signature and surrounding
doc comment so it matches project formatting rules and CI expectations, ensuring
the pub fn grow(...) line and its parameters are wrapped/indented according to
rustfmt output; locate the grow function definition and apply cargo fmt to fix
the formatting.

In `@persona/src/main.rs`:
- Around line 537-545: Replace the direct bulk UPDATE in run_orphan_cleanup with
logic that resolves candidate stale assignments by selecting persona_assignments
rows where assigned_to_session IS NOT NULL and last_assigned <
datetime('now','-24 hours'), then for each candidate verify the session is
actually orphaned and call the existing release path (release::release) inside a
DB transaction so persona_assignments.released_at is set atomically; use
open_db() -> conn.transaction() (or equivalent) and iterate the selected rows,
invoking release::release(transaction, persona_id, session_id) rather than
executing the raw UPDATE.
- Around line 85-92: Rust formatting violations are causing CI failure; run
rustfmt (cargo fmt) and reformat the file so it passes cargo fmt --check.
Specifically ensure consistent formatting/indentation for the output.insert
calls that involve "assignments" and "preambles" (the output.insert(...
serde_json::to_value(&assignments)? ) and output.insert(...
serde_json::to_value(&preambles)? ) expressions) and similarly reformat the
other affected blocks around the regions noted (lines containing output.insert
usage and nearby code around the ranges you were called out for) so they comply
with rustfmt rules, then commit the formatted file.
- Line 410: The summary insertion and subsequent marking of source rows must be
atomic: use a single DB transaction obtained from the existing connection (conn)
so the insert of the summary row and all updates that mark source rows compacted
are committed together (or rolled back on error). Wrap the insert/update loop
(the code that inserts the summary row and marks source rows compacted around
lines 410 and 459-481) in a transaction (e.g., acquire transaction from conn,
perform the summary INSERT and all UPDATEs using the transaction handle instead
of conn, then commit; on any error rollback), and ensure all DB operations in
that flow use the transaction handle to avoid partial commits and duplicate
summaries on retry.

In `@persona/src/release.rs`:
- Around line 10-18: The POSITIVE_KEYWORDS and NEGATIVE_KEYWORDS array
formatting does not match rustfmt rules required by CI; run `cargo fmt` or
reformat the two constants (`POSITIVE_KEYWORDS`, `NEGATIVE_KEYWORDS`) so they
conform to rustfmt style (wrap/align elements and trailing commas as rustfmt
produces) and then re-run the formatter to ensure no formatting diffs remain
before pushing.
- Around line 55-65: The SQL block starting at the conn.prepare call is
misformatted; run rustfmt (cargo fmt) and adjust the multi-line string so it
conforms to rustfmt rules (e.g., wrap the SQL literal consistently) in the
function containing role_counts and total, specifically updating the
conn.prepare("SELECT role, observation FROM persona_feedback WHERE persona_id =
?1")? and the following stmt.query_map(...) closure that reads role and
observation for persona_id; then commit the formatted changes so CI passes.

In `@persona/tests/integration_test.rs`:
- Around line 131-135: The assert! formatting in the test block using
role_counts_json, counts, total, and a.name doesn't match rustfmt; run cargo fmt
to reformat the file (or manually reflow the assert! invocation in
persona/tests/integration_test.rs so the macro's arguments are properly
formatted) and commit the formatted changes so CI no longer reports a formatting
diff.

---

Outside diff comments:
In `@Makefile`:
- Around line 47-50: The deploy loop in the Makefile copies only binaries listed
in the for-loop (session-start permissions changelog session-end ensure-worktree
memory) and omits the new persona binaries, so deployed installs lack
persona-inject and muzzle-persona; update the loop or copy list to include
persona-inject and muzzle-persona (or add a separate cp for those names) so that
persona-inject and muzzle-persona from target/release are copied into
$(DEPLOY_TARGET)/bin/, ensuring the deploy step ships the new persona binaries
referenced by hooks/src/bin/session_end.rs and registration code.

---

Nitpick comments:
In `@CLAUDE.md`:
- Around line 56-66: The fenced code block showing repository files is missing a
language specifier; update the triple-backtick fence that precedes the list (the
block containing lib.rs, types.rs, schema.rs, etc.) to include a language
identifier (use "text" to match other source layout blocks) so the block becomes
```text and remains closed with ```.

In `@persona/src/grow.rs`:
- Around line 16-24: from_time() seeds Rng using only subsec_nanos(), which can
collide under rapid calls; update Rng::from_time to mix in additional entropy
(e.g., SystemTime::now().as_secs(), process id via std::process::id(), and/or a
small atomic counter) into the seed before constructing Rng so consecutive calls
produce different u64 seeds; combine values with XOR/rotations or a simple
mixing function and return Rng(seed) from from_time() to avoid identical name
sequences.

In `@persona/src/release.rs`:
- Around line 181-189: The UPDATE uses datetime('now', '-30 days') which yields
"YYYY-MM-DD HH:MM:SS" while last_assigned is stored via now_iso8601()
("YYYY-MM-DDTHH:MM:SSZ"); change the right-hand expression so both timestamps
use the same ISO8601 format—e.g. replace datetime('now', '-30 days') in the SQL
executed by conn.execute(...) with strftime('%Y-%m-%dT%H:%M:%SZ','now','-30
days') so the comparison against last_assigned remains consistent and
unambiguous.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6cacc5da-9fdf-43d9-badd-4d74814bab6f

📥 Commits

Reviewing files that changed from the base of the PR and between 1312071 and f1aedfd.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (18)
  • CLAUDE.md
  • Cargo.toml
  • Makefile
  • hooks/Cargo.toml
  • hooks/src/bin/persona_inject.rs
  • hooks/src/bin/session_end.rs
  • persona/Cargo.toml
  • persona/personas-seed.toml
  • persona/src/broker.rs
  • persona/src/grow.rs
  • persona/src/lib.rs
  • persona/src/main.rs
  • persona/src/preamble.rs
  • persona/src/release.rs
  • persona/src/schema.rs
  • persona/src/seed.rs
  • persona/src/types.rs
  • persona/tests/integration_test.rs

Comment on lines +39 to +43
fn main() {
// Fail-open on panic (NOT deny — this is persona injection, not security)
let _ = std::panic::catch_unwind(run);
// If run() returned or panicked, exit 0 (passthrough)
}
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Panic handling must deny instead of silently passing through.

The unwind path currently exits 0 with no hook response, so a hook panic becomes an implicit allow. Emit a deny response on panic instead.

Suggested fix
 fn main() {
-    // Fail-open on panic (NOT deny — this is persona injection, not security)
-    let _ = std::panic::catch_unwind(run);
-    // If run() returned or panicked, exit 0 (passthrough)
+    if std::panic::catch_unwind(run).is_err() {
+        print_deny("persona-inject panicked");
+    }
 }
fn print_deny(reason: &str) {
    let response = HookResponse {
        hook_specific_output: HookSpecificOutput {
            hook_event_name: "PreToolUse".to_string(),
            permission_decision: "deny".to_string(),
            permission_decision_reason: Some(reason.to_string()),
            updated_input: None,
        },
    };

    if let Ok(json) = serde_json::to_string(&response) {
        println!("{json}");
    }
}

As per coding guidelines, "hooks/src/bin/*.rs: All panic scenarios in hooks must be caught and result in denial rather than fail-open behavior".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hooks/src/bin/persona_inject.rs` around lines 39 - 43, The current main uses
let _ = std::panic::catch_unwind(run); which swallows panics and causes implicit
allow; implement a print_deny(reason: &str) function (using HookResponse and
HookSpecificOutput as shown) that serializes to JSON and prints it, then change
main to match on std::panic::catch_unwind(run) and on Err(e) call print_deny
with a concise reason (e.g., panic message or "panic in run") before exiting;
ensure serde_json::to_string is used and HookResponse/HookSpecificOutput field
names exactly match existing types so any panic path emits a deny response
instead of failing open.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

persona-inject is NOT a security gate. CLAUDE.md explicitly documents this exception: "except persona-inject which fails open — persona injection is not a security gate." Denying on panic would incorrectly block agent calls when persona DB is unavailable.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@frits-v, understood and acknowledged. The fail-open behavior for persona-inject is the correct design — denying on panic would block legitimate agent calls whenever the persona DB is simply unavailable, which would be unacceptable for a non-security feature. My earlier comment was mistaken in applying the blanket deny-on-panic rule here. I'll update the knowledge base accordingly.

The existing broad learning that says all hooks/src/bin/*.rs panics must deny is now inaccurate given this exception. I'd suggest removing it — would you like me to do that? The learning ID is 63736f69-683c-4f32-8262-fc417a6d0974. If confirmed, I can remove it and replace it with a more precise scoped rule (covering all hook binaries except persona-inject).


✏️ Learnings added
Learnt from: frits-v
Repo: frits-v/muzzle PR: 41
File: hooks/src/bin/persona_inject.rs:39-43
Timestamp: 2026-03-25T18:43:59.922Z
Learning: In `hooks/src/bin/persona_inject.rs`, `persona-inject` is explicitly documented as a fail-open exception: panics and all errors result in passthrough (exit 0, no denial). This is intentional — persona injection is NOT a security gate, so blocking agent calls when the persona DB is unavailable would be incorrect. CLAUDE.md documents: "except persona-inject which fails open — persona injection is not a security gate."

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: CR
Repo: frits-v/muzzle PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-24T22:09:39.468Z
Learning: Applies to hooks/src/bin/permissions.rs : PreToolUse hook (`permissions` binary) MUST NEVER write files — use `resolve_readonly()` for H-4 purity enforcement

Learnt from: CR
Repo: frits-v/muzzle PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-24T22:09:39.468Z
Learning: Applies to hooks/src/bin/*.rs : All panic scenarios in hooks must be caught and result in denial rather than fail-open behavior

Comment on lines +157 to +168
let mut cmd = std::process::Command::new(&persona_bin);
cmd.arg("assign")
.arg(&roles_arg)
.arg(&project_arg)
.arg(&session_arg)
.arg(&agent_arg);

if let Some(tn) = team_name {
cmd.arg(format!("--team-name={tn}"));
}

let output = cmd.output().ok()?;
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Put a deadline on the persona subprocess.

This call runs inline on every eligible Agent request. If muzzle-persona wedges on SQLite or the executable hangs, the entire PreToolUse path blocks indefinitely instead of falling back cleanly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hooks/src/bin/persona_inject.rs` around lines 157 - 168, The persona
subprocess is invoked with cmd.output().ok()? which can block indefinitely;
change to spawn the process (use cmd.spawn()), wait for completion with a
bounded timeout (use std::time::Duration and a wait-with-timeout strategy such
as the wait-timeout crate or a manual loop), and if the timeout elapses kill the
child and return an error or fallback value instead of blocking; update uses of
persona_bin, cmd, and output handling to use Child from cmd.spawn(), enforce the
timeout, and handle kill/cleanup and error logging before returning.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The subprocess does SQLite I/O benchmarked at 2.3ms. The hook is fail-open — if it hangs, the agent proceeds without persona. Adding timeout complexity is not justified for a best-effort personalization hook.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@frits-v, understood — the fail-open design plus the 2.3 ms benchmark is a reasonable basis for this tradeoff on a best-effort hook. I'll drop the suggestion.

One small note for the record: cmd.output() only falls through if the child exits (even abnormally); a truly wedged process (e.g., SQLite exclusive lock held by another writer, or an FS stall) would still block the hook indefinitely regardless of the fail-open logic. If that risk ever materialises in practice, a one-shot wait-timeout wrapper would be the fix. For now, your call is fine.


✏️ Learnings added
Learnt from: frits-v
Repo: frits-v/muzzle PR: 41
File: hooks/src/bin/persona_inject.rs:158-169
Timestamp: 2026-03-25T18:44:07.567Z
Learning: In `hooks/src/bin/persona_inject.rs`, the `persona-inject` PreToolUse hook intentionally uses `cmd.output().ok()?` without a timeout. The `muzzle-persona assign` subprocess performs SQLite I/O benchmarked at ~2.3 ms. The hook is fail-open by design (best-effort personalization, not a security gate), so adding subprocess timeout complexity is considered unjustified by the project author.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: CR
Repo: frits-v/muzzle PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-24T22:09:39.468Z
Learning: Applies to hooks/src/bin/permissions.rs : PreToolUse hook (`permissions` binary) MUST NEVER write files — use `resolve_readonly()` for H-4 purity enforcement

Learnt from: CR
Repo: frits-v/muzzle PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-24T22:09:39.468Z
Learning: Applies to hooks/src/bin/*.rs : All panic scenarios in hooks must be caught and result in denial rather than fail-open behavior

Comment on lines +8 to +12
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
rusqlite = { version = "0.32", features = ["bundled"] }
toml = "0.8"
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "--- persona/Cargo.toml"
sed -n '/^\[dependencies\]/,/^\[/p;/^\[dev-dependencies\]/,/^\[/p' persona/Cargo.toml

Repository: frits-v/muzzle

Length of output: 236


Remove rusqlite and toml dependencies from persona/Cargo.toml.

The crate manifest violates the repo dependency allowlist. Only these 5 dependencies are permitted: serde, serde_json, regex, flate2, libc.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@persona/Cargo.toml` around lines 8 - 12, Remove the disallowed dependencies
by deleting the rusqlite and toml entries from the persona crate manifest
(remove the lines for rusqlite = { version = "0.32", features = ["bundled"] }
and toml = "0.8" in the [dependencies] section) so the crate only depends on the
allowed packages (serde, serde_json, regex, flate2, libc); if any code
references rusqlite or toml (search for usages of rusqlite:: and toml:: or
functions that parse TOML), replace those paths with permitted alternatives or
refactor to avoid those crates before committing the manifest change.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dependency allowlist in CLAUDE.md applies to the hooks crate only. The memory crate already uses rusqlite. The persona crate is a new workspace member with its own deps — rusqlite (database) and toml (seed parsing) are intentional.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check CLAUDE.md for dependency allowlist scope
grep -n -A5 -B5 "rusqlite\|allowlist\|dep\|only these\|serde\|flate2\|libc" CLAUDE.md | head -80

echo "---"
# Check if a memory crate exists and uses rusqlite
fd "Cargo.toml" --exec grep -l "rusqlite" {}

echo "---"
# Check workspace Cargo.toml to see all members
cat Cargo.toml

Length of output: 1334


🏁 Script executed:

#!/bin/bash
# Get more context around the Dependencies section in CLAUDE.md
sed -n '250,285p' CLAUDE.md

echo "--- memory/Cargo.toml ---"
cat memory/Cargo.toml

Length of output: 953


@frits-v, you're right — I can confirm that memory/Cargo.toml already uses rusqlite = { version = "0.32", features = ["bundled"] }, establishing the precedent for SQLite-backed crates in this workspace. The CLAUDE.md Dependencies section doesn't explicitly scope itself to the hooks crate only, which led to my overly broad learning. I'll correct that now.

I'll remove the incorrect learning and replace it with a scoped one:


✏️ Learnings added
Learnt from: frits-v
Repo: frits-v/muzzle PR: 41
File: persona/Cargo.toml:8-12
Timestamp: 2026-03-25T18:44:30.003Z
Learning: Applies to hooks/Cargo.toml only: The 5-dependency allowlist (serde, serde_json, regex, flate2, libc — no async runtime, network deps, or proc macros) from CLAUDE.md's Dependencies section applies exclusively to the hooks crate. Other workspace crates (memory, persona) have their own dependency sets; e.g., memory/Cargo.toml and persona/Cargo.toml both use rusqlite = { version = "0.32", features = ["bundled"] }, and persona/Cargo.toml also uses toml = "0.8" for seed parsing.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


⛔ Learnings removed
Learnt from: CR
Repo: frits-v/muzzle PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-24T22:09:39.468Z
Learning: Applies to {Cargo.toml,**/Cargo.toml} : Use only these 5 dependencies: serde, serde_json, regex, flate2, libc. No async runtime, network deps, or proc macros.

🧠 Learnings used
Learnt from: CR
Repo: frits-v/muzzle PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-24T22:09:39.468Z
Learning: Applies to {Cargo.toml,**/Cargo.toml} : Use only these 5 dependencies: serde, serde_json, regex, flate2, libc. No async runtime, network deps, or proc macros.

Learnt from: CR
Repo: frits-v/muzzle PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-24T22:09:39.468Z
Learning: Applies to hooks/src/**/*.rs : Session tests must use `SESSION_LOCK` mutex to avoid PPID marker conflicts

Comment on lines +537 to +545
fn run_orphan_cleanup(_args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
let conn = open_db()?;
let updated = conn.execute(
"UPDATE personas SET assigned_to_session = NULL
WHERE assigned_to_session IS NOT NULL
AND last_assigned < datetime('now', '-24 hours')",
[],
)?;
println!("orphan cleanup: {updated} stale lock(s) cleared");
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Route orphan cleanup through the normal release path.

This bulk UPDATE bypasses release::release from Lines 98-103, so matching persona_assignments rows never get released_at. It also unlocks anything older than 24 hours, including a still-running long session. Resolve stale session IDs, verify they are actually orphaned, then release them transactionally.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@persona/src/main.rs` around lines 537 - 545, Replace the direct bulk UPDATE
in run_orphan_cleanup with logic that resolves candidate stale assignments by
selecting persona_assignments rows where assigned_to_session IS NOT NULL and
last_assigned < datetime('now','-24 hours'), then for each candidate verify the
session is actually orphaned and call the existing release path
(release::release) inside a DB transaction so persona_assignments.released_at is
set atomically; use open_db() -> conn.transaction() (or equivalent) and iterate
the selected rows, invoking release::release(transaction, persona_id,
session_id) rather than executing the raw UPDATE.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

orphan-cleanup targets dead sessions (>24h stale). Their assignments are already abandoned — calling release::release per-persona would recompute affinity from stale data. The simple UPDATE is appropriate for cleanup of genuinely dead locks.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@frits-v, that's a fair point — recomputing affinity from abandoned session data would corrupt scores in a way that's hard to undo, so skipping the full release::release path is a reasonable design choice here.

The one residual gap worth considering (independent of affinity) is that persona_assignments.released_at stays NULL for these orphaned rows. Queries that scan for unreleased assignments (e.g., in release::release for a subsequent session) may encounter those ghost rows. A lightweight fix that doesn't touch affinity would be to stamp released_at on the matching persona_assignments rows in the same cleanup pass:

fn run_orphan_cleanup(_args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
    let conn = open_db()?;
    let now = now_iso8601();

    // Collect stale session IDs first.
    let mut stmt = conn.prepare(
        "SELECT DISTINCT assigned_to_session
           FROM personas
          WHERE assigned_to_session IS NOT NULL
            AND last_assigned < datetime('now', '-24 hours')",
    )?;
    let stale_sessions: Vec<String> = stmt
        .query_map([], |r| r.get(0))?
        .filter_map(|r| r.ok())
        .collect();

    for session_id in &stale_sessions {
        // Stamp released_at on assignments without touching affinity.
        conn.execute(
            "UPDATE persona_assignments SET released_at = ?1
              WHERE assigned_to_session = ?2 AND released_at IS NULL",
            params![now, session_id],
        )?;
    }

    let updated = conn.execute(
        "UPDATE personas SET assigned_to_session = NULL
          WHERE assigned_to_session IS NOT NULL
            AND last_assigned < datetime('now', '-24 hours')",
        [],
    )?;
    println!("orphan cleanup: {updated} stale lock(s) cleared");
    Ok(())
}

This keeps affinity untouched while ensuring persona_assignments stays consistent. That said, if your system already treats any row without released_at as "potentially in-flight" and you have another mechanism to distinguish orphans, the current approach is also fine. Happy to close this out if you prefer to keep it as-is.

- Fix cargo fmt formatting across all persona files
- Parameterize SQL in CLI list/history commands (no string interpolation)
- Use full as_nanos() for RNG seed instead of subsec_nanos()
- Check grow() return value before reloading empty pool
- Serialize --roles JSON properly via serde_json (handles special chars)
- Log muzzle-persona release failures in session-end hook
- Wrap feedback compaction in atomic transaction
Comment on lines +579 to +586
fn run_orphan_cleanup(_args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
let conn = open_db()?;
let updated = conn.execute(
"UPDATE personas SET assigned_to_session = NULL
WHERE assigned_to_session IS NOT NULL
AND last_assigned < datetime('now', '-24 hours')",
[],
)?;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 ISO 8601 T separator breaks SQLite datetime comparisons

now_iso8601() stores timestamps in the format 2026-03-24T14:30:00Z (with T and Z), but SQLite's datetime() function returns 2026-03-24 14:30:00 (space separator, no Z). Because SQLite compares timestamps as plain TEXT lexicographically, this causes a systematic failure: T (ASCII 84) is greater than space (ASCII 32), so every stored timestamp on the same calendar day as the cutoff will compare as "later than" the cutoff, and the < predicate returns FALSE.

Concrete example: a lock set at 2026-03-24T13:00:00Z (25 hours ago) will NOT be cleared when datetime('now', '-24 hours') = 2026-03-24 14:00:00, because "2026-03-24T..." > "2026-03-24 ...".

The fix is to normalize the stored value before comparison, or to use SQLite's own datetime() for the stored value:

AND datetime(last_assigned) < datetime('now', '-24 hours')

SQLite's datetime() accepts ISO 8601 strings with T separators and normalizes them to the space-separated form, making the comparison consistent.

The same issue also affects the compact subcommand (persona/src/main.rs:443: pf.timestamp < datetime('now', ?1)) and the auto-retire step in release.rs (line 201: last_assigned < datetime('now', '-30 days')). All three sites need the same fix.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
persona/src/main.rs (1)

579-589: ⚠️ Potential issue | 🟠 Major

Route orphan cleanup through the normal release path.

This bulk UPDATE bypasses release::release, so matching persona_assignments rows never get released_at set, and affinity scores won't be recomputed. It also unlocks anything older than 24 hours unconditionally, which could affect a legitimately long-running session.

Consider iterating over candidate stale sessions and calling release::release for each to ensure released_at is stamped and role counts / affinity scores are updated.

🛠️ Sketch of a safer approach
 fn run_orphan_cleanup(_args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
     let conn = open_db()?;
-    let updated = conn.execute(
-        "UPDATE personas SET assigned_to_session = NULL
-          WHERE assigned_to_session IS NOT NULL
-            AND last_assigned < datetime('now', '-24 hours')",
-        [],
-    )?;
-    println!("orphan cleanup: {updated} stale lock(s) cleared");
+    // Find distinct stale sessions
+    let mut stmt = conn.prepare(
+        "SELECT DISTINCT assigned_to_session FROM personas
+          WHERE assigned_to_session IS NOT NULL
+            AND last_assigned < datetime('now', '-24 hours')",
+    )?;
+    let sessions: Vec<String> = stmt
+        .query_map([], |r| r.get(0))?
+        .filter_map(|r| r.ok())
+        .collect();
+
+    let mut released = 0;
+    for session_id in &sessions {
+        if release::release(&conn, session_id).is_ok() {
+            released += 1;
+        }
+    }
+    println!("orphan cleanup: released {released} stale session(s)");
     Ok(())
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@persona/src/main.rs` around lines 579 - 589, The current run_orphan_cleanup
function does a bulk UPDATE on the personas table which bypasses the normal
release path (release::release), so persona_assignments rows never get
released_at set and affinity/role counts are not recomputed; change
run_orphan_cleanup to query for candidate stale assignments (rows in personas
where assigned_to_session IS NOT NULL and last_assigned < datetime('now', '-24
hours')), iterate those results, and for each call release::release with the
session/assignment identifiers so released_at is stamped and affinity
scores/role counts are recomputed; ensure you use open_db() to load candidates
and avoid the unconditional bulk UPDATE—only call release::release for sessions
that meet the safety threshold and handle errors per-call.
🧹 Nitpick comments (2)
persona/src/broker.rs (1)

288-300: Clarify the intent of unconditional lock-clearing for active personas in find_summon.

The docstring claims "clear any stale session lock," but lines 296–299 unconditionally clear assigned_to_session for active personas without validating staleness. The last_assigned field is selected but never checked. If this is intentional (summon as a forced override), the docstring should say so. If staleness validation was intended, use the same time-based check as orphan cleanup (e.g., last_assigned < datetime('now', '-24 hours')), or remove the word "stale" from the docstring.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@persona/src/broker.rs` around lines 288 - 300, The code in find_summon
unconditionally clears assigned_to_session for active personas but never checks
last_assigned to verify staleness; either update the docstring to state this is
a forced override or change the behaviour to only clear locks when last_assigned
is stale (use the same time-based logic as orphan cleanup, e.g., compare
last_assigned < datetime('now','-24 hours')) and make the UPDATE for active
personas conditional on that check; look for the find_summon function and the
UPDATE statements that touch assigned_to_session and last_assigned and adjust
the docstring or add the time-based conditional before executing the UPDATE.
persona/src/main.rs (1)

31-52: Add WAL mode configuration to open_db() for consistency with the memory crate.

Both crates share $HOME/.muzzle/memory.db. The memory crate's Store::open() explicitly configures WAL mode via pragma_update_and_check(), but open_db() here does not. While the memory crate safely handles journal mode changes when it opens the database, configuring WAL mode in both crates ensures consistent behavior from the start and avoids unnecessary mode changes on first run.

Consider adding:

let _: String = conn.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))?;

after Connection::open() in open_db(), matching the memory crate's configuration.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@persona/src/main.rs` around lines 31 - 52, open_db() opens the shared SQLite
file but doesn't set WAL journaling like the memory crate; after
Connection::open(&db_path)? and before ensure_schema(&conn)? call
conn.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))?
(capture the returned String into a throwaway binding) to configure WAL mode
consistently; keep the existing ensure_schema and seed logic unchanged so
behavior remains identical except for the journal_mode change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@persona/src/grow.rs`:
- Around line 104-106: Replace the DoubleEndedIterator pattern using
filter(...).next_back() in grow.rs with Iterator::rfind to satisfy the clippy
filter-next lint: instead of (0..vocab.len()).filter(|&i| Some(i) !=
exclude).next_back(), call (0..vocab.len()).rfind(...) and use a closure that
checks the same predicate against each item (referencing vocab and exclude) so
the result semantics remain identical.

---

Duplicate comments:
In `@persona/src/main.rs`:
- Around line 579-589: The current run_orphan_cleanup function does a bulk
UPDATE on the personas table which bypasses the normal release path
(release::release), so persona_assignments rows never get released_at set and
affinity/role counts are not recomputed; change run_orphan_cleanup to query for
candidate stale assignments (rows in personas where assigned_to_session IS NOT
NULL and last_assigned < datetime('now', '-24 hours')), iterate those results,
and for each call release::release with the session/assignment identifiers so
released_at is stamped and affinity scores/role counts are recomputed; ensure
you use open_db() to load candidates and avoid the unconditional bulk
UPDATE—only call release::release for sessions that meet the safety threshold
and handle errors per-call.

---

Nitpick comments:
In `@persona/src/broker.rs`:
- Around line 288-300: The code in find_summon unconditionally clears
assigned_to_session for active personas but never checks last_assigned to verify
staleness; either update the docstring to state this is a forced override or
change the behaviour to only clear locks when last_assigned is stale (use the
same time-based logic as orphan cleanup, e.g., compare last_assigned <
datetime('now','-24 hours')) and make the UPDATE for active personas conditional
on that check; look for the find_summon function and the UPDATE statements that
touch assigned_to_session and last_assigned and adjust the docstring or add the
time-based conditional before executing the UPDATE.

In `@persona/src/main.rs`:
- Around line 31-52: open_db() opens the shared SQLite file but doesn't set WAL
journaling like the memory crate; after Connection::open(&db_path)? and before
ensure_schema(&conn)? call conn.pragma_update_and_check(None, "journal_mode",
"WAL", |row| row.get(0))? (capture the returned String into a throwaway binding)
to configure WAL mode consistently; keep the existing ensure_schema and seed
logic unchanged so behavior remains identical except for the journal_mode
change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b17428fc-f784-4e2b-9f09-e72f540a0416

📥 Commits

Reviewing files that changed from the base of the PR and between f1aedfd and d85f018.

📒 Files selected for processing (7)
  • hooks/src/bin/persona_inject.rs
  • hooks/src/bin/session_end.rs
  • persona/src/broker.rs
  • persona/src/grow.rs
  • persona/src/main.rs
  • persona/src/release.rs
  • persona/tests/integration_test.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • hooks/src/bin/persona_inject.rs

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
persona/src/grow.rs (1)

148-196: Consider wrapping the insert loop in a transaction for atomicity.

If grow(conn, meta, 5, rng) is called and the third insert fails, the first two personas remain in the database. While not critical for this use case (partial growth is still valid progress), wrapping in BEGIN IMMEDIATE/COMMIT would ensure all-or-nothing semantics consistent with the broker's transaction pattern.

♻️ Optional: wrap in transaction
+    let tx = conn.unchecked_transaction()?;
     for _ in 0..count {
         // ... existing loop body ...
     }
+    tx.commit()?;
 
     Ok(inserted)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@persona/src/grow.rs` around lines 148 - 196, The loop in grow that performs
multiple INSERTs uses the raw connection (conn.query_row and conn.execute), so
partial inserts can persist if one insert fails; wrap the multi-insert operation
in a database transaction: begin a rusqlite Transaction at the start of the
for-loop block (e.g., let tx = conn.transaction()?), switch calls from
conn.query_row/conn.execute to use the transaction (tx.query_row / tx.execute or
equivalent), and commit the transaction after the loop (tx.commit()), letting
any error propagate to cause an automatic rollback so the whole grow call is
atomic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@persona/src/grow.rs`:
- Around line 148-196: The loop in grow that performs multiple INSERTs uses the
raw connection (conn.query_row and conn.execute), so partial inserts can persist
if one insert fails; wrap the multi-insert operation in a database transaction:
begin a rusqlite Transaction at the start of the for-loop block (e.g., let tx =
conn.transaction()?), switch calls from conn.query_row/conn.execute to use the
transaction (tx.query_row / tx.execute or equivalent), and commit the
transaction after the loop (tx.commit()), letting any error propagate to cause
an automatic rollback so the whole grow call is atomic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 300f30ed-6fd1-456e-8a42-cf36a2ca4cad

📥 Commits

Reviewing files that changed from the base of the PR and between d85f018 and ae261f5.

📒 Files selected for processing (2)
  • persona/src/grow.rs
  • persona/tests/integration_test.rs

Comment on lines +167 to +170
}

let output = cmd.output().ok()?;
if !output.status.success() {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 No subprocess timeout — hook can stall indefinitely

std::process::Command::output() blocks until the child exits. If muzzle-persona assign hangs — for example due to SQLite WAL lock contention when many sessions start simultaneously — the persona-inject PreToolUse hook will block without bound, stalling every agent call matched by is_persona_eligible. Since the hook is fail-open, the consequence isn't a denial, but all agent invocations pause until either the subprocess finishes or Claude Code kills the hook process.

Consider wrapping the call with a deadline using wait_timeout via std::process::Child, or at minimum spawning with a short timeout:

// Spawn, then wait with a timeout.
let mut child = cmd.spawn().ok()?;
let output = child
    .wait_timeout(std::time::Duration::from_secs(5))
    .ok()
    .flatten()
    .filter(|s| s.success())?;
// Read stdout after confirming success.

Note: wait_timeout is in the wait-timeout crate; alternatively, std::process::Child::wait in a thread with std::sync::mpsc gives a timeout without an extra dependency.

Comment on lines +75 to +76
conn.prepare("SELECT role, observation FROM persona_feedback WHERE persona_id = ?1")?;
let rows = stmt.query_map(params![persona_id], |row| {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 recompute_affinity counts compacted rows, making compact a no-op for scoring

The query fetches every feedback row for a persona with no filter on compacted. After the compact subcommand runs, the original N feedback rows are still in the table (now marked compacted = 1), and a new summary row (with neutral text like "Compacted 2026-03 (5 feedback entries…)") is added, also with compacted = 1. Because both old observations and the summary pass this query, the original sentiment signals are still counted in full, and nothing changes in the computed affinity score — the compact operation has zero practical effect on scoring.

The fix is to exclude already-compacted originals and only consider the summary rows, or to only count rows where compacted = 0:

SELECT role, observation
  FROM persona_feedback
 WHERE persona_id = ?1
   AND compacted = 0

This way, once feedback is compacted, the originals no longer affect scoring (the summary row has neutral sentiment and thus a near-zero contribution), which matches the expected semantic of "old, compacted feedback no longer dominates".

Comment on lines +196 to +205
// Step 6: retire long-inactive personas.
conn.execute(
"UPDATE personas
SET status = 'archived'
WHERE status = 'active'
AND last_assigned < datetime('now', '-30 days')
AND assigned_to_session IS NULL",
[],
)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 ISO 8601 T separator causes auto-retire to silently skip eligible personas

last_assigned is stored by now_iso8601() in YYYY-MM-DDTHH:MM:SSZ format (e.g., 2026-03-20T09:00:00Z), but SQLite's datetime('now', '-30 days') returns YYYY-MM-DD HH:MM:SS (space separator, no Z). Because SQLite compares these as plain text lexicographically, T (ASCII 84) > (ASCII 32), so any last_assigned timestamp on the same calendar day as the cutoff compares as greater-than the cutoff and the predicate last_assigned < datetime(...) returns FALSE — the persona is not retired even if it is genuinely inactive.

Wrapping last_assigned with SQLite's own datetime() normalizes the format before comparison:

UPDATE personas
    SET status = 'archived'
  WHERE status = 'active'
    AND datetime(last_assigned) < datetime('now', '-30 days')
    AND assigned_to_session IS NULL

SQLite's datetime() function accepts ISO 8601 strings with both T separators and Z suffixes and normalises them to the space-separator form, making the comparison reliable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant