Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .claude/commands/pm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
description: Act as the project's PM — bare /pm enables PM mode and runs the daily kickoff; subcommands give targeted reports (standup, groom, plan, health, portfolio)
argument-hint: [standup|groom|plan|health|portfolio|mode] [args]
---

Parse "$ARGUMENTS": the first word (if any) is the subcommand; everything after
it is that subcommand's arguments.

## Bare `/pm` (no arguments) — start the PM day

1. Load the `pm-mode` skill and stay in PM mode (create and dispatch work
instead of implementing it yourself) until `/pm mode off`.
2. Load the `pm` skill and run the daily kickoff, in this order:
a. **Standup** — `/pm:standup` workflow (last 24h): what shipped, what's in
flight per assignee, what's stuck.
b. **Intake triage** — `/pm:groom` workflow's flag lists only: new/unassigned
items, blocked items, likely duplicates, unestimated items.
c. **Blockers first** — for each stuck or blocked item, say what unblocks it
and who should act.
d. **Pull next** — the groom `pull_next` shortlist, cross-checked against
priorities.
3. Close with a **morning briefing**: ≤10 lines — Done / In flight / Stuck /
Recommended dispatches — then propose the concrete dispatch actions PM mode
allows (assignments, handoffs, item creation) and wait for approval before
executing any of them.

## Subcommands (targeted, read-only reports — no PM mode change)

- **standup** `[cutoff-hours]` — `pm` skill `/pm:standup`; cutoff default 24.
- **groom** `[staleness-days] [rice|wsjf|value-effort]` — `/pm:groom`;
staleness default 14, framework default `rice`.
- **plan** `[~capacity] [rice|wsjf|value-effort]` — `/pm:plan`; capacity hint
like `~8` caps the Now bucket.
- **health** `[window-weeks]` — `/pm:health`; window default 4.
- **portfolio** `[standup|health] [args]` — `/pm:portfolio`: the chosen report
(default `health`) rolled up across every project in the workspace.
- **mode on** — enable PM mode without the daily kickoff.
- **mode off** — leave PM mode: stop following `pm-mode`, return to normal
implementation behavior, confirm in one line.

The `pm` skill's report workflows are read-only over items — item mutations
happen only through PM-mode dispatch actions the user has approved. Unknown
subcommand → one-line usage summary, then stop.
34 changes: 27 additions & 7 deletions .claude/skills/pm/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: pm
description: Product management for the current agentflare project — run /pm:standup (daily activity digest), /pm:groom (backlog grooming + RICE/ICE/WSJF/Value-Effort prioritization), /pm:plan (Now/Next/Later sprint bucketing), or /pm:health (velocity + WIP + bottleneck scorecard). Read-only; operates on agentflare items via MCP.
description: Product management for the current agentflare project — run /pm:standup (daily activity digest), /pm:groom (backlog grooming + RICE/ICE/WSJF/Value-Effort prioritization), /pm:plan (Now/Next/Later sprint bucketing), /pm:health (velocity + WIP + bottleneck scorecard), or /pm:portfolio (cross-project roll-up). Read-only; operates on agentflare items via MCP.
---

# PM Agent — product management over agentflare items
Expand All @@ -17,8 +17,10 @@ All content authored from public PM methodologies (RICE, ICE, WSJF, Value-Effort

## Scope

One project only — whichever project the current repo resolves to. No
cross-project aggregation.
Default: one project — whichever project the current repo resolves to.
`/pm:portfolio` is the one exception: it loops the read-only reports across
every project in the workspace via the `project` override param (still
read-only, still one workspace).

## Workflows

Expand Down Expand Up @@ -96,9 +98,27 @@ Arg: window in weeks (default 4).
returns `velocity` (oldest→newest weekly series + `velocity_trend`:
up/down/flat), `wip` (list + count), `stuck` (WIP older than
`staleness_days`, default 7), and `bottlenecks`/`bottleneck_note`.
2. `bottlenecks` is currently always empty — agentflare has no persisted
handoff-history log distinct from item state yet, so this can't be
computed server-side. Print `bottleneck_note` verbatim ("no handoff
history") rather than inventing a signal.
2. `bottlenecks` lists items handed between different agents ≥2× inside the
window, computed server-side from the persisted assignment log (written on
every claim/reassignment). Print each entry as returned (`#N name — K
handoffs (owner chain)`), plus `bottleneck_note` — it carries the one
caveat that matters: history starts at the assignment-log migration, so
older transitions are invisible.
3. One-glance scorecard: Velocity · WIP · Stuck · Bottlenecks.
4. Print the time-signal caveat. Read-only.

### /pm:portfolio — cross-project roll-up

Args: which report (`health` default, or `standup`); the report's own args
pass through (window weeks / cutoff hours).

1. One call: `project action="list"` — every project in the linked workspace.
2. For each project, one call: `item action="<health|standup>"
project=<project name>` — the `project` override is honored only by the
read-only reporting actions, so this stays mutation-free by construction.
3. Print one roll-up table, one row per project:
- health: project · velocity trend · WIP · stuck · bottleneck count.
- standup: project · done · in-progress · stuck counts.
Follow with a short "needs attention" list: any project with stuck items,
a `down` velocity trend, or non-empty bottlenecks, and why.
4. Print the time-signal caveat once (it applies to every row). Read-only.
232 changes: 232 additions & 0 deletions crates/agentflare-backend/src/assignment_events.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
//! Append-only log of item assignee transitions — the persisted "handoff
//! history" the health scorecard's bottleneck signal reads. Rows are written
//! by [`crate::item::update`] whenever `assignee_agent` actually changes
//! (which also covers `claim`, since claiming assigns through `update`).
//! History starts at the migration that shipped the table; transitions
//! before it are unrecorded.

use rusqlite::Connection;

use crate::item::agent_part;

fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}

/// One item's handoff activity within a window: how many times it moved
/// between *different* agents (instance suffixes stripped — `claude:1` →
/// `claude:2` is not a handoff), and the distinct owner chain in order of
/// first appearance.
#[derive(Debug)]
pub struct HandoffStat {
pub item_id: String,
pub handoffs: usize,
pub owners: Vec<String>,
}

/// Records one assignee transition. Called from `item::update` inside the
/// caller's transaction so the event commits (or rolls back) with the
/// assignment itself.
pub(crate) fn record(
conn: &Connection,
item_id: &str,
from_owner: Option<&str>,
to_owner: &str,
) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO item_assignment_events (id, item_id, from_owner, to_owner, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![db_kit::ids::new_id(), item_id, from_owner, to_owner, now()],
)?;
Ok(())
}

/// Handoff stats per item for a project, over events at or after `since`.
/// Only items with at least one agent-to-agent handoff are returned; the
/// caller picks its own "repeatedly" threshold.
pub fn handoff_stats_since(
conn: &Connection,
project_id: &str,
since: i64,
) -> crate::error::Result<Vec<HandoffStat>> {
let mut stmt = conn.prepare(
"SELECT e.item_id, e.from_owner, e.to_owner
FROM item_assignment_events e
JOIN items i ON i.id = e.item_id
WHERE i.project_id = ?1 AND i.deleted_at IS NULL AND e.created_at >= ?2
ORDER BY e.item_id, e.created_at",
)?;
let rows = stmt.query_map(rusqlite::params![project_id, since], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, String>(2)?,
))
})?;

let mut stats: Vec<HandoffStat> = Vec::new();
for row in rows {
let (item_id, from_owner, to_owner) = row?;
let to_agent = agent_part(&to_owner);
if stats.last().map(|s| s.item_id.as_str()) != Some(item_id.as_str()) {
stats.push(HandoffStat {
item_id,
handoffs: 0,
owners: Vec::new(),
});
}
let stat = stats.last_mut().expect("pushed above");
if from_owner
.as_deref()
.is_some_and(|f| agent_part(f) != to_agent)
{
stat.handoffs += 1;
}
if let Some(from) = from_owner.as_deref().map(agent_part)
&& !stat.owners.contains(&from)
{
stat.owners.push(from);
}
if !stat.owners.contains(&to_agent) {
stat.owners.push(to_agent);
}
}
stats.retain(|s| s.handoffs >= 1);
Ok(stats)
}

#[cfg(test)]
mod tests {
use super::*;
use crate::{item, project, state, workspace};

fn seed() -> (rusqlite::Connection, String, String) {
let conn = crate::db::open_in_memory().unwrap();
let ws = workspace::create(
&conn,
workspace::CreateWorkspace {
name: "W".into(),
slug: "w".into(),
item_label: None,
owner_agent: None,
},
)
.unwrap();
let proj = project::create(
&conn,
project::CreateProject {
workspace_id: ws.id,
name: "P".into(),
identifier: "P".into(),
external_source: None,
external_id: None,
},
)
.unwrap();
let backlog = state::list_by_project(&conn, &proj.id)
.unwrap()
.into_iter()
.find(|s| s.group_name == "backlog")
.unwrap();
(conn, proj.id, backlog.id)
}

fn make_item(conn: &rusqlite::Connection, pid: &str, sid: &str) -> item::Item {
item::create(
conn,
item::CreateItem {
project_id: pid.to_string(),
state_id: sid.to_string(),
name: "I".into(),
description: None,
priority: None,
parent_id: None,
assignee_agent: None,
sort_order: None,
external_source: None,
external_id: None,
metadata: None,
label_ids: Vec::new(),
assignee_ids: Vec::new(),
dependency_ids: Vec::new(),
},
)
.unwrap()
}

fn assign(conn: &rusqlite::Connection, id: &str, agent: &str) {
item::update(
conn,
id,
item::UpdateItem {
assignee_agent: Some(agent.to_string()),
..Default::default()
},
)
.unwrap();
}

#[test]
fn update_records_a_transition_only_when_the_assignee_changes() {
let (conn, pid, sid) = seed();
let it = make_item(&conn, &pid, &sid);
assign(&conn, &it.id, "alice"); // None -> alice
assign(&conn, &it.id, "alice"); // no change, no event
assign(&conn, &it.id, "bob"); // alice -> bob
let count: i64 = conn
.query_row(
"SELECT count(*) FROM item_assignment_events WHERE item_id = ?1",
[&it.id],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 2);
}

#[test]
fn claim_records_a_transition_through_update() {
let (conn, pid, sid) = seed();
let it = make_item(&conn, &pid, &sid);
item::claim(&conn, &it.id, "alice:1", 1000, 600).unwrap();
let count: i64 = conn
.query_row(
"SELECT count(*) FROM item_assignment_events WHERE item_id = ?1",
[&it.id],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
}

#[test]
fn handoff_stats_count_agent_changes_not_first_assignment_or_instances() {
let (conn, pid, sid) = seed();
let it = make_item(&conn, &pid, &sid);
assign(&conn, &it.id, "alice"); // first assignment — not a handoff
assign(&conn, &it.id, "alice:2"); // same agent, other instance — not a handoff
assign(&conn, &it.id, "bob"); // handoff 1
assign(&conn, &it.id, "carol"); // handoff 2
let stats = handoff_stats_since(&conn, &pid, 0).unwrap();
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].handoffs, 2);
assert_eq!(stats[0].owners, vec!["alice", "bob", "carol"]);
}

#[test]
fn handoff_stats_respect_the_since_cutoff_and_skip_no_handoff_items() {
let (conn, pid, sid) = seed();
let it = make_item(&conn, &pid, &sid);
assign(&conn, &it.id, "alice"); // only a first assignment
assert!(handoff_stats_since(&conn, &pid, 0).unwrap().is_empty());
assign(&conn, &it.id, "bob");
let far_future = now() + 10_000;
assert!(
handoff_stats_since(&conn, &pid, far_future)
.unwrap()
.is_empty()
);
}
}
12 changes: 12 additions & 0 deletions crates/agentflare-backend/src/item/crud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,13 @@ pub fn update(conn: &Connection, id: &str, input: UpdateItem) -> Result<Item> {
.assignee_agent
.as_deref()
.map(agent_registry::canonicalize);
// Snapshot the outgoing assignee before the write so the assignment log
// can record the transition (only fetched when the assignee is changing).
let previous_assignee = if assignee_agent.is_some() {
Some(get(conn, id)?.assignee_agent)
} else {
None
};
let mut sets = vec!["updated_at = ?2".to_string()];
let mut param_idx = 3;
if input.name.is_some() {
Expand Down Expand Up @@ -231,6 +238,11 @@ pub fn update(conn: &Connection, id: &str, input: UpdateItem) -> Result<Item> {
if changed == 0 {
return Err(crate::error::Error::NotFound(id.to_string()));
}
if let (Some(new_assignee), Some(old_assignee)) = (&assignee_agent, &previous_assignee)
&& old_assignee.as_deref() != Some(new_assignee.as_str())
{
crate::assignment_events::record(conn, id, old_assignee.as_deref(), new_assignee)?;
}
let item = get(conn, id)?;
if let Ok(wid) = workspace_id_for_project(conn, &item.project_id) {
events::emit(
Expand Down
1 change: 1 addition & 0 deletions crates/agentflare-backend/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod ask_event;
pub mod asset;
pub mod assignment_events;
pub mod bridge_repo;
pub mod claim;
pub mod comment;
Expand Down
Loading
Loading