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
89 changes: 50 additions & 39 deletions .claude/skills/pm/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ description: Product management for the current agentflare project — run /pm:s
These workflows NEVER mutate items. Do not call `item` with any of:
create, update, update_state, delete, claim, heartbeat, release, done, cancel,
add_label, remove_label — nor `comment` create/edit/delete. You may only read
(`item` list/get/search, `comment` list, `handoff` inbox, `memory`). Output is
suggestions for a human, never actions taken.
(`item` list/get/search/groom/standup/health, `comment` list, `handoff` inbox, `memory`).
Output is suggestions for a human, never actions taken.

All content authored from public PM methodologies (RICE, ICE, MoSCoW, Now/Next/Later). No third-party notices required.

Expand All @@ -29,57 +29,68 @@ health additionally use `reference/rubric.md`.

Arg: cutoff (default: items with `updated_at` within the last 24h).

1. Read items: `item action="list" state_group="started,completed"`.
2. Bucket:
- **Done** — state_group=completed, updated_at ≥ cutoff.
- **In progress** — state_group=started (all), grouped by assignee_agent.
- **Stuck** — in-progress items whose updated_at is older than 7 days.
3. For each item print `FIX-NN · <name> · <assignee or unassigned>`.
4. Print the read-recipe time-signal caveat.
1. One call: `item action="standup" cutoff_hours=<hours, default 24>`. The
server returns `done` (completed within cutoff_hours), `in_progress`
(grouped by assignee, "unassigned" as its own group), and `stuck`
(in-progress items older than `staleness_days`, default 7) — already
bucketed, no hand-sorting a flat `list` result.
2. For each item print `FIX-NN · <name> · <assignee or unassigned>`.
3. Print the read-recipe time-signal caveat.
Read-only: never change item state.

### /pm:groom — backlog grooming + prioritization

Arg: staleness threshold in days (default 14).

1. Read open items: `item action="list" state_group="backlog,unstarted"`.
2. Shortlist the top candidates by `priority` (urgent>high>medium>low>none),
cap 15, and `item action="get"` each for description/labels.
3. Score each shortlisted item with reference/rubric.md (RICE, ICE fallback).
Print a ranked table: rank · FIX-NN · name · score · one-line reason.
4. Flag lists (from the full open list, no get needed):
- **Stale**: updated_at older than &lt;threshold&gt; days.
- **Unassigned**: assignee_agent is null.
- **Likely duplicates**: items whose names are near-identical (same key tokens).
- **Unestimated**: no size/effort signal (from the shortlist gets).
5. **Pull next**: top 3 ranked items that are unassigned and not stale.
6. Print the time-signal caveat. Read-only.
1. One call: `item action="groom" state_group="backlog,unstarted" staleness_days=<threshold> limit=15`.
This replaces the old `list` + N×`get` + hand-computed flags — the server
already returns the shortlist (priority + recency ranked, full description)
with `stale`, `unassigned`, `blocked_by`, `depended_on_by_count`,
`possible_duplicates`, `size`/`unestimated` precomputed per item, plus
`pull_next` and the summary counts. Do not re-derive these by eyeballing
timestamps or text — they're already computed.
2. Score each shortlisted item with reference/rubric.md (RICE using the
returned `size` where present, ICE fallback where `unestimated=true`) —
your judgment is only needed for Reach and Confidence, which the server
can't infer from free text. Print a ranked table: rank · FIX-NN · name ·
score · one-line reason.
3. Flag lists — read straight from the response, no recomputation:
- **Stale**: items with `stale=true`.
- **Unassigned**: items with `unassigned=true` (`unassigned_count` for the total).
- **Blocked**: items with non-empty `blocked_by`.
- **Likely duplicates**: items with non-empty `possible_duplicates`.
- **Unestimated**: items with `unestimated=true` (`unestimated_count` for the
total) — recommend adding `metadata={"size":"S"|"M"|"L"}` via `item(update)`.
4. **Pull next**: the response's `pull_next` (top 3 unassigned/not-stale/unblocked
by rank) — cross-check against your RICE ranking and note if they diverge.
5. Print the time-signal caveat. Read-only — `groom` only reads.

### /pm:plan — Now / Next / Later bucketing

Arg: capacity hint like "~8" (optional; caps the Now bucket).

1. Reuse the groom ranking (steps 1–3 of /pm:groom).
2. Bucket by rank and readiness:
- **Now**: highest-ranked items that are ready (have an estimate, not blocked
by an open dependency). Cap to the capacity hint if provided.
- **Next**: next tier by rank.
- **Later**: the tail + anything low-confidence.
3. Separately list **Needs estimation** (unestimated items) — cannot be planned.
4. Print each bucket as an ordered list of `FIX-NN · name · score`.
5. Print the time-signal caveat. Read-only — this proposes a plan, it does not
1. One call: `item action="groom" state_group="backlog,unstarted" capacity=<hint or a sane default like 5>`.
The server does the bucketing: `now` (top-`capacity` ready items — unblocked,
has a `size`), `next` (remaining ready items), `later` (blocked items),
`needs_estimation` (unestimated — excluded from planning). No hand-bucketing.
2. Score each item with reference/rubric.md for the printed rationale (RICE
using `size`, ICE fallback for `unestimated` ones) — your judgment covers
Reach/Confidence, the buckets themselves are already computed.
3. Print each bucket as an ordered list of `FIX-NN · name · score`.
4. Print the time-signal caveat. Read-only — this proposes a plan, it does not
assign or move items.

### /pm:health — team health scorecard

Arg: window in weeks (default 4).

1. Velocity: `item action="list" state_group="completed"`; per rubric.md, count
items whose `updated_at` falls in each trailing 7-day window; show the series
and the trend arrow.
2. WIP: `item action="list" state_group="started"`; report the count and list.
3. Stuck: WIP items with `updated_at` older than 7 days.
4. Bottlenecks: read `handoff` history (read-only) for items handed off
repeatedly; if none available, print "no handoff history".
5. One-glance scorecard: Velocity · WIP · Stuck · Bottlenecks.
6. Print the time-signal caveat. Read-only.
1. One call: `item action="health" window_weeks=<N, default 4>`. The server
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.
3. One-glance scorecard: Velocity · WIP · Stuck · Bottlenecks.
4. Print the time-signal caveat. Read-only.
24 changes: 21 additions & 3 deletions .claude/skills/pm/reference/read-recipe.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,29 @@ Call `item` with `action="list"`. Add filters as needed:
The list projection has ONLY: id, name, state, state_group, priority,
assignee_agent, parent_id, sequence_id, updated_at.

## Standup: one call, not list+bucket

`item action="standup"` (optional `cutoff_hours` default 24, `staleness_days`
default 7 for the stuck threshold) returns `done`/`in_progress` (grouped by
assignee)/`stuck` pre-bucketed server-side, plus counts. Use this instead of
`list` + hand-sorting for standup.

## Grooming/plan: one call, not list+N×get

`item action="groom"` (optional `state_group`, `staleness_days` default 14,
`limit` default 15) returns the priority+recency-ranked shortlist with full
description AND precomputed `stale`/`unassigned`/`blocked_by`/
`depended_on_by_count`/`possible_duplicates`/`size`/`unestimated`, plus
`pull_next` and summary counts — computed server-side in one round trip.
Do not fall back to `list` + per-item `get` for grooming/planning; that was
the old N+1 path this action replaces.

## Detail fetch (only when needed)

`item action="get" id=<id>` returns the full item incl. description, metadata,
labels, timestamps. Grooming/plan fetch detail ONLY for the shortlisted items
(cap at the top 15) to stay bounded.
`item action="get" id=<id>` returns one full item incl. description, metadata,
timestamps — for a single ad-hoc lookup outside grooming, not for building a
shortlist (use `groom` for that). Labels are a separate join, not part of
this response.

## Time signals — approximate, state this in output

Expand Down
25 changes: 23 additions & 2 deletions .claude/skills/pm/reference/rubric.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ Map each factor to a fixed 1–5 from readable signals. Show the reason inline.
`customer`, `revenue`, `priority:high|urgent`. 1 trivial … 5 critical.
- Confidence — how well-specified the item is (has a clear description/acceptance).
1 vague … 5 crisp.
- Effort — size. From a `size:S|M|L` label or an estimate in the body.
1 = large/expensive … 5 = tiny. If unknown, mark UNESTIMATED (see below).
- Effort — size. From `groom`'s `size` field (parsed server-side from
`metadata.size`, set via `item(update)` with `metadata={"size":"S"|"M"|"L"}`).
1 = large/expensive … 5 = tiny. `groom` sets `unestimated=true` when
`size` is absent — treat that as UNESTIMATED (see below), don't guess a
size from description prose.

Print each score as: `RICE 9.6 — R4 I5 C3 / E? (UNESTIMATED)` with one-line why.

Expand All @@ -18,6 +21,24 @@ Print each score as: `RICE 9.6 — R4 I5 C3 / E? (UNESTIMATED)` with one-line wh
When items lack any effort/size signal, use ICE = Impact × Confidence × Ease
(1–5 each) and label the table "ICE (no effort estimates present)".

## Sizing an unestimated item (mutating — outside the read-only workflows)

`groom`/`plan` never call `item(update)` — sizing is a deliberate exception,
done only when a human directly asks you to size specific items, not as an
automatic step in any workflow.

When asked, don't guess uniformly:
- **Self-contained description** (states its own size, or is a trivially
small single fix) — size directly from the text.
- **Judgment call** (the estimate depends on how much of this already exists,
how tangled the current code is, or how much is genuinely new) — verify
against the actual codebase first (`ctx_compose`/`ctx_search`/`ctx_read`)
before committing a size via `item(update) metadata={"size":...}`. Trusting
an item's own scope claims without checking is how a real L gets shipped as
M — found live in this project (#100 claimed reusable ledgers that don't
exist anywhere in the codebase; verifying caught it, the description alone
would not have).

## Unestimated handling

Never fail. Score what you can, mark the missing factor `?`, and list all
Expand Down
28 changes: 24 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,35 @@ a separate sidecar not in agentflare's read path.

## Context compression — lean-ctx

Prefer [lean-ctx](https://github.com/yvgude/lean-ctx) tools over native equivalents:
read files with its compressed reader, run shell commands through its
compression wrapper, search code with its search/callgraph tools, orient in
unfamiliar code with its composed-context command before exploring.
**MANDATORY for code intelligence — do NOT use native Grep / Read-on-full-file /
shell `cat`/`grep`/`rg`/`find` to search or read code. Route ALL of it through
lean-ctx instead.** lean-ctx is in shadow mode: native file/search/shell calls
auto-route to `ctx_*` — but the rule below is the contract so agents without
shadow routing (Aider, plain shells) still comply.

- **Code search** → `ctx_search` (action=regex | semantic | symbol), NOT Grep/grep/rg.
- exact symbol: `ctx_search(action=symbol, name=...)`
- by meaning: `ctx_search(action=semantic, query=...)` (uses the on-demand
dense index — no pre-build needed)
- by pattern: `ctx_search(action=regex, pattern=...)`
- **Callers/callees** → `ctx_callgraph` (NOT grep for "who calls X").
- **Orient in unfamiliar code** → `ctx_compose` FIRST (one call vs
search→read→search chain).
- **Read files** → `ctx_read` (compressed reader), prefer mode=anchored/full.
Recover a compressed read verbatim via `ctx_read mode=raw`.
- **Shell** → `ctx_shell` (auto-compresses output).

Native `cat`/`grep`/`rg`/`find`/`Read`-whole-file are ONLY for: writing files,
git status/diff you will act on, and non-code text. Everything code-intelligence
goes through lean-ctx so the index stays the single source of truth.

```bash
npm install -g lean-ctx-bin && lean-ctx onboard
```

If `ctx_*` tools are genuinely unavailable in your runtime, fall back to the
native Grep/Read — but that is the exception, and you must say so.

## Cross-session memory

agentflare ships persistent memory in the binary itself — no separate
Expand Down
65 changes: 65 additions & 0 deletions crates/agentflare-backend/src/item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ pub struct UpdateItem {
pub state_id: Option<String>,
pub assignee_agent: Option<String>,
pub sort_order: Option<f64>,
pub metadata: Option<String>,
}

fn now() -> i64 {
Expand Down Expand Up @@ -247,6 +248,10 @@ pub fn update(conn: &Connection, id: &str, input: UpdateItem) -> Result<Item> {
}
if input.sort_order.is_some() {
sets.push(format!("sort_order = ?{param_idx}"));
param_idx += 1;
}
if input.metadata.is_some() {
sets.push(format!("metadata = ?{param_idx}"));
}
let sql = format!(
"UPDATE items SET {} WHERE id = ?1 AND deleted_at IS NULL",
Expand Down Expand Up @@ -274,6 +279,9 @@ pub fn update(conn: &Connection, id: &str, input: UpdateItem) -> Result<Item> {
if let Some(so) = input.sort_order {
param_values.push(Box::new(so));
}
if let Some(ref metadata) = input.metadata {
param_values.push(Box::new(metadata.clone()));
}
let changed = stmt.execute(rusqlite::params_from_iter(param_values.iter()))?;
if changed == 0 {
return Err(crate::error::Error::NotFound(id.to_string()));
Expand Down Expand Up @@ -440,6 +448,63 @@ pub fn list_dependencies(conn: &Connection, item_id: &str) -> Result<Vec<String>
Ok(rows.collect::<std::result::Result<_, _>>()?)
}

/// Dependency edges for a set of items, with each edge's target state_group
/// already joined in — so a caller's blocking status is correct even when
/// the dependency target isn't itself in the same shortlist/limit window
/// (e.g. a completed dependency that fell outside `groom`'s cap must not
/// read back as an open blocker just because its state wasn't looked up).
/// `(item_id, depends_on_item_id, depends_on_state_group)`.
pub fn dependency_edges_for_items(
conn: &Connection,
item_ids: &[String],
) -> Result<Vec<(String, String, String)>> {
if item_ids.is_empty() {
return Ok(vec![]);
}
let placeholders = item_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
let sql = format!(
"SELECT d.item_id, d.depends_on_item_id, s.group_name
FROM item_dependencies d
JOIN items i ON i.id = d.depends_on_item_id AND i.deleted_at IS NULL
JOIN states s ON s.id = i.state_id
WHERE d.item_id IN ({placeholders})"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(item_ids.iter()), |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}

/// Fan-in counts: for each of `item_ids`, how many other (non-deleted) items
/// declare a dependency on it — project-wide, not limited to the same
/// shortlist/limit window a caller happens to be looking at.
pub fn dependency_fanin_for_items(
conn: &Connection,
item_ids: &[String],
) -> Result<std::collections::HashMap<String, i64>> {
if item_ids.is_empty() {
return Ok(std::collections::HashMap::new());
}
let placeholders = item_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
let sql = format!(
"SELECT d.depends_on_item_id, COUNT(*)
FROM item_dependencies d
JOIN items i ON i.id = d.item_id AND i.deleted_at IS NULL
WHERE d.depends_on_item_id IN ({placeholders})
GROUP BY d.depends_on_item_id"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(item_ids.iter()), |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}

/// FTS5 search across items (name, description, metadata) within a project.
/// Returns BM25-ranked results, most relevant first. Query is sanitised
/// via `flare-search-kit` into safe FTS5 tokens (quoted, operators
Expand Down
6 changes: 3 additions & 3 deletions opencode.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"lsp": {
"rust-analyzer": {
"enabled": true,
"settings": {
"rust": {
"command": ["rust-analyzer"],
"initialization": {
"rust-analyzer.checkOnSave": true,
"rust-analyzer.check.command": "clippy",
"rust-analyzer.cargo.features": "all",
Expand Down
2 changes: 1 addition & 1 deletion src/auth_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ pub fn run(agent: &str, args: &[String], json: bool) {
if !json {
eprintln!("retrying with new profile ({remaining} retries left)...");
}
// ponytail: short backoff, linear increase if rate limits persist
// flare-code: short backoff, linear increase if rate limits persist
thread::sleep(Duration::from_secs(1 + (MAX_RETRIES - remaining) as u64));
}
ExitKind::Failure(code) => {
Expand Down
Loading
Loading