3 Commits

Author SHA1 Message Date
teernisse
597095a283 chore: update beads tracker state
Sync beads issue database to JSONL for version control tracking.
2026-02-20 14:25:28 -05:00
teernisse
d0e88abe85 feat(me): add lore me personal work dashboard command
Implement a personal work dashboard that shows everything relevant to the
configured GitLab user: open issues assigned to them, MRs they authored,
MRs they are reviewing, and a chronological activity feed.

Design decisions:
- Attention state computed from GitLab interaction data (comments, reviews)
  with no local state tracking -- purely derived from existing synced data
- Username resolution: --user flag > config.gitlab.username > actionable error
- Project scoping: --project (fuzzy) | --all | default_project | all
- Section filtering: --issues, --mrs, --activity (combinable, default = all)
- Activity feed controlled by --since (default 30d); work item sections
  always show all open items regardless of --since

Architecture (src/cli/commands/me/):
- types.rs: MeDashboard, MeSummary, AttentionState data types
- queries.rs: 4 SQL queries (open_issues, authored_mrs, reviewing_mrs,
  activity) using existing issue_assignees, mr_reviewers, notes tables
- render_human.rs: colored terminal output with attention state indicators
- render_robot.rs: {ok, data, meta} JSON envelope with field selection
- mod.rs: orchestration (resolve_username, resolve_project_scope, run_me)
- me_tests.rs: comprehensive unit tests covering all query paths

Config additions:
- New optional gitlab.username field in config.json
- Tests for config with/without username
- Existing test configs updated with username: None

CLI wiring:
- MeArgs struct with section filter, since, project, all, user, fields flags
- Autocorrect support for me command flags
- LoreRenderer::try_get() for safe renderer access in me module
- Robot mode field selection presets (me_items, me_activity)
- handle_me() in main.rs command dispatch

Also fixes duplicate assertions in surgical sync tests (removed 6
duplicate assert! lines that were copy-paste artifacts).

Spec: docs/lore-me-spec.md
2026-02-20 14:25:20 -05:00
teernisse
cb6894798e docs: migrate agent coordination from MCP Agent Mail to Liquid Mail
Replace all MCP Agent Mail references with Liquid Mail in AGENTS.md and
CLAUDE.md. The old system used file reservations and MCP-based messaging
with inbox/outbox/thread semantics. Liquid Mail provides a simpler
post-based shared log with topic-scoped messages, decision conflict
detection, and polling via the liquid-mail CLI.

Key changes:
- Remove entire MCP Agent Mail section (identity registration, file
  reservations, macros vs granular tools, common pitfalls)
- Update Beads integration workflow to reference Liquid Mail: replace
  reservation + announce patterns with post-based progress logging and
  decision-first workflows
- Update bv scope boundary note to reference Liquid Mail
- Append full Liquid Mail integration block to CLAUDE.md: conventions,
  typical flow, decision conflicts, posting format, topic rules, context
  refresh, live updates, mapping cheat-sheet, quick reference
- Add .liquid-mail.toml project configuration (Honcho backend)
2026-02-20 14:25:08 -05:00
5 changed files with 77 additions and 125 deletions

View File

@@ -97,11 +97,14 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
let single_project = project_ids.len() == 1;
// 5. Parse --since (default 30d for activity feed, AC-2.3)
let since_ms = args
.since
.as_deref()
.and_then(parse_since)
.unwrap_or_else(|| crate::core::time::now_ms() - DEFAULT_ACTIVITY_SINCE_DAYS * MS_PER_DAY);
let since_ms = match args.since.as_deref() {
Some(raw) => parse_since(raw).ok_or_else(|| {
LoreError::Other(format!(
"Invalid --since value '{raw}'. Expected: 7d, 2w, 3m, YYYY-MM-DD, or Unix-ms timestamp."
))
})?,
None => crate::core::time::now_ms() - DEFAULT_ACTIVITY_SINCE_DAYS * MS_PER_DAY,
};
// 6. Determine which sections to query
let show_all = args.show_all_sections();
@@ -184,7 +187,7 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
if robot_mode {
let fields = args.fields.as_deref();
render_robot::print_me_json(&dashboard, elapsed_ms, fields);
render_robot::print_me_json(&dashboard, elapsed_ms, fields)?;
} else if show_all {
render_human::print_me_dashboard(&dashboard, single_project);
} else {

View File

@@ -10,6 +10,9 @@ use crate::core::error::Result;
use super::types::{ActivityEventType, AttentionState, MeActivityEvent, MeIssue, MeMr};
/// Stale threshold: items with no activity for 30 days are marked "stale".
const STALE_THRESHOLD_MS: i64 = 30 * 24 * 3600 * 1000;
// ─── Open Issues (AC-5.1, Task #7) ─────────────────────────────────────────
/// Query open issues assigned to the user via issue_assignees.
@@ -23,62 +26,47 @@ pub fn query_open_issues(
let project_clause = build_project_clause("i.project_id", project_ids);
let sql = format!(
"WITH my_latest AS (
SELECT d.issue_id, MAX(n.created_at) AS ts
"WITH note_ts AS (
SELECT d.issue_id,
MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts,
MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts,
MAX(n.created_at) AS any_ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username = ?1 AND n.is_system = 0
AND d.issue_id IS NOT NULL
GROUP BY d.issue_id
),
others_latest AS (
SELECT d.issue_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username != ?1 AND n.is_system = 0
AND d.issue_id IS NOT NULL
GROUP BY d.issue_id
),
any_latest AS (
SELECT d.issue_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.is_system = 0
AND d.issue_id IS NOT NULL
WHERE n.is_system = 0 AND d.issue_id IS NOT NULL
GROUP BY d.issue_id
)
SELECT i.iid, i.title, p.path_with_namespace, i.status_name, i.updated_at, i.web_url,
CASE
WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts)
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
THEN 'needs_attention'
WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000)
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
THEN 'stale'
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0)
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
THEN 'awaiting_response'
ELSE 'not_started'
END AS attention_state
FROM issues i
JOIN issue_assignees ia ON ia.issue_id = i.id
JOIN projects p ON i.project_id = p.id
LEFT JOIN my_latest ml ON ml.issue_id = i.id
LEFT JOIN others_latest ol ON ol.issue_id = i.id
LEFT JOIN any_latest al ON al.issue_id = i.id
LEFT JOIN note_ts nt ON nt.issue_id = i.id
WHERE ia.username = ?1
AND i.state = 'opened'
{project_clause}
ORDER BY
CASE
WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts)
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
THEN 0
WHEN al.ts IS NULL AND ml.ts IS NULL
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL
THEN 1
WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000)
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
THEN 3
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0)
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
THEN 2
ELSE 1
END,
i.updated_at DESC"
i.updated_at DESC",
stale_ms = STALE_THRESHOLD_MS,
);
let params = build_params(username, project_ids);
@@ -115,28 +103,14 @@ pub fn query_authored_mrs(
let project_clause = build_project_clause("m.project_id", project_ids);
let sql = format!(
"WITH my_latest AS (
SELECT d.merge_request_id, MAX(n.created_at) AS ts
"WITH note_ts AS (
SELECT d.merge_request_id,
MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts,
MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts,
MAX(n.created_at) AS any_ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username = ?1 AND n.is_system = 0
AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
),
others_latest AS (
SELECT d.merge_request_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username != ?1 AND n.is_system = 0
AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
),
any_latest AS (
SELECT d.merge_request_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.is_system = 0
AND d.merge_request_id IS NOT NULL
WHERE n.is_system = 0 AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
)
SELECT m.iid, m.title, p.path_with_namespace, m.draft, m.detailed_merge_status,
@@ -145,32 +119,31 @@ pub fn query_authored_mrs(
WHEN m.draft = 1 AND NOT EXISTS (
SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id
) THEN 'not_ready'
WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts)
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
THEN 'needs_attention'
WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000)
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
THEN 'stale'
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0)
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
THEN 'awaiting_response'
ELSE 'not_started'
END AS attention_state
FROM merge_requests m
JOIN projects p ON m.project_id = p.id
LEFT JOIN my_latest ml ON ml.merge_request_id = m.id
LEFT JOIN others_latest ol ON ol.merge_request_id = m.id
LEFT JOIN any_latest al ON al.merge_request_id = m.id
LEFT JOIN note_ts nt ON nt.merge_request_id = m.id
WHERE m.author_username = ?1
AND m.state = 'opened'
{project_clause}
ORDER BY
CASE
WHEN m.draft = 1 AND NOT EXISTS (SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id) THEN 4
WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts) THEN 0
WHEN al.ts IS NULL AND ml.ts IS NULL THEN 1
WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000) THEN 3
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0) THEN 2
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) THEN 0
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) THEN 2
ELSE 1
END,
m.updated_at DESC"
m.updated_at DESC",
stale_ms = STALE_THRESHOLD_MS,
);
let params = build_params(username, project_ids);
@@ -209,63 +182,45 @@ pub fn query_reviewing_mrs(
let project_clause = build_project_clause("m.project_id", project_ids);
let sql = format!(
"WITH my_latest AS (
SELECT d.merge_request_id, MAX(n.created_at) AS ts
"WITH note_ts AS (
SELECT d.merge_request_id,
MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts,
MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts,
MAX(n.created_at) AS any_ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username = ?1 AND n.is_system = 0
AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
),
others_latest AS (
SELECT d.merge_request_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username != ?1 AND n.is_system = 0
AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
),
any_latest AS (
SELECT d.merge_request_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.is_system = 0
AND d.merge_request_id IS NOT NULL
WHERE n.is_system = 0 AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
)
SELECT m.iid, m.title, p.path_with_namespace, m.draft, m.detailed_merge_status,
m.author_username, m.updated_at, m.web_url,
CASE
WHEN m.draft = 1 AND NOT EXISTS (
SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id
) THEN 'not_ready'
WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts)
-- not_ready is impossible here: JOIN mr_reviewers guarantees a reviewer exists
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
THEN 'needs_attention'
WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000)
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
THEN 'stale'
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0)
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
THEN 'awaiting_response'
ELSE 'not_started'
END AS attention_state
FROM merge_requests m
JOIN mr_reviewers r ON r.merge_request_id = m.id
JOIN projects p ON m.project_id = p.id
LEFT JOIN my_latest ml ON ml.merge_request_id = m.id
LEFT JOIN others_latest ol ON ol.merge_request_id = m.id
LEFT JOIN any_latest al ON al.merge_request_id = m.id
LEFT JOIN note_ts nt ON nt.merge_request_id = m.id
WHERE r.username = ?1
AND m.state = 'opened'
{project_clause}
ORDER BY
CASE
WHEN m.draft = 1 AND NOT EXISTS (SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id) THEN 4
WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts) THEN 0
WHEN al.ts IS NULL AND ml.ts IS NULL THEN 1
WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000) THEN 3
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0) THEN 2
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) THEN 0
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) THEN 2
ELSE 1
END,
m.updated_at DESC"
m.updated_at DESC",
stale_ms = STALE_THRESHOLD_MS,
);
let params = build_params(username, project_ids);
@@ -552,7 +507,7 @@ fn build_params(username: &str, project_ids: &[i64]) -> Vec<Box<dyn rusqlite::ty
params
}
/// Populate labels for issues (avoids N+1 when there are few issues).
/// Populate labels for issues via cached per-item queries.
fn populate_issue_labels(conn: &Connection, issues: &mut [MeIssue]) -> Result<()> {
if issues.is_empty() {
return Ok(());
@@ -576,7 +531,7 @@ fn populate_issue_labels(conn: &Connection, issues: &mut [MeIssue]) -> Result<()
Ok(())
}
/// Populate labels for MRs (avoids N+1 when there are few MRs).
/// Populate labels for MRs via cached per-item queries.
fn populate_mr_labels(conn: &Connection, mrs: &mut [MeMr]) -> Result<()> {
if mrs.is_empty() {
return Ok(());

View File

@@ -138,6 +138,7 @@ fn print_attention_legend() {
(AttentionState::NotStarted, "not started"),
(AttentionState::AwaitingResponse, "awaiting response"),
(AttentionState::Stale, "stale (30d+)"),
(AttentionState::NotReady, "draft (not ready)"),
];
let legend: Vec<String> = states
@@ -369,7 +370,7 @@ fn format_entity_ref(entity_type: &str, iid: i64) -> String {
let s = format!("#{iid}");
Theme::issue_ref().render(&s)
}
"merge_request" => {
"mr" => {
let s = format!("!{iid}");
Theme::mr_ref().render(&s)
}
@@ -440,7 +441,7 @@ mod tests {
#[test]
fn format_entity_ref_mr() {
let result = format_entity_ref("merge_request", 99);
let result = format_entity_ref("mr", 99);
assert!(result.contains("99"), "got: {result}");
}

View File

@@ -10,20 +10,19 @@ use super::types::{
// ─── Robot JSON Output (Task #18) ────────────────────────────────────────────
/// Print the full me dashboard as robot-mode JSON.
pub fn print_me_json(dashboard: &MeDashboard, elapsed_ms: u64, fields: Option<&[String]>) {
pub fn print_me_json(
dashboard: &MeDashboard,
elapsed_ms: u64,
fields: Option<&[String]>,
) -> crate::core::error::Result<()> {
let envelope = MeJsonEnvelope {
ok: true,
data: MeDataJson::from_dashboard(dashboard),
meta: RobotMeta { elapsed_ms },
};
let mut value = match serde_json::to_value(&envelope) {
Ok(v) => v,
Err(e) => {
eprintln!("Error serializing me JSON: {e}");
return;
}
};
let mut value = serde_json::to_value(&envelope)
.map_err(|e| crate::core::error::LoreError::Other(format!("JSON serialization: {e}")))?;
// Apply --fields filtering (Task #19)
if let Some(f) = fields {
@@ -38,10 +37,10 @@ pub fn print_me_json(dashboard: &MeDashboard, elapsed_ms: u64, fields: Option<&[
crate::cli::robot::filter_fields(&mut value, "activity", &activity_expanded);
}
match serde_json::to_string(&value) {
Ok(json) => println!("{json}"),
Err(e) => eprintln!("Error serializing to JSON: {e}"),
}
let json = serde_json::to_string(&value)
.map_err(|e| crate::core::error::LoreError::Other(format!("JSON serialization: {e}")))?;
println!("{json}");
Ok(())
}
// ─── JSON Envelope ───────────────────────────────────────────────────────────

View File

@@ -168,7 +168,6 @@ fn test_ingest_issue_by_iid_upserts_and_marks_dirty() {
let result = ingest_issue_by_iid(&conn, &config, 1, &issue).unwrap();
assert!(!result.skipped_stale);
assert!(!result.skipped_stale);
assert!(!result.dirty_source_keys.is_empty());
@@ -200,7 +199,6 @@ fn test_toctou_skips_stale_issue() {
// Second ingest with same timestamp should be skipped
let r2 = ingest_issue_by_iid(&conn, &config, 1, &issue).unwrap();
assert!(r2.skipped_stale);
assert!(r2.skipped_stale);
assert!(r2.dirty_source_keys.is_empty());
// No new dirty mark
@@ -224,7 +222,6 @@ fn test_toctou_allows_newer_issue() {
let result = ingest_issue_by_iid(&conn, &config, 1, &issue_t2).unwrap();
assert!(!result.skipped_stale);
assert!(!result.skipped_stale);
}
#[test]
@@ -272,7 +269,6 @@ fn test_ingest_mr_by_iid_upserts_and_marks_dirty() {
let result = ingest_mr_by_iid(&conn, &config, 1, &mr).unwrap();
assert!(!result.skipped_stale);
assert!(!result.skipped_stale);
assert!(!result.dirty_source_keys.is_empty());
@@ -299,7 +295,6 @@ fn test_toctou_skips_stale_mr() {
let r2 = ingest_mr_by_iid(&conn, &config, 1, &mr).unwrap();
assert!(r2.skipped_stale);
assert!(r2.skipped_stale);
assert!(r2.dirty_source_keys.is_empty());
}
@@ -317,7 +312,6 @@ fn test_toctou_allows_newer_mr() {
let result = ingest_mr_by_iid(&conn, &config, 1, &mr_t2).unwrap();
assert!(!result.skipped_stale);
assert!(!result.skipped_stale);
}
#[test]