diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index ed624f8f..a507111c 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2150,3 +2150,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-144', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 22:41:28', '2026-05-22 22:41:28', '2026-05-22 22:41:28', NULL, 'eda6f75e88401ee5b445e7edfc312f82', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-144', 'status', 'in_progress', 'done', NULL, '2026-05-22 22:43:45', '2026-05-22 22:43:45', '2026-05-22 22:43:45', NULL, 'da17b2710e17b1821882d47724790cbb', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-139', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 22:50:41', '2026-05-22 22:50:41', '2026-05-22 22:50:41', NULL, 'ad063f0c3c65e5b35d3dcea036412ad9', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-139', 'status', 'in_progress', 'done', NULL, '2026-05-22 23:16:28', '2026-05-22 23:16:28', '2026-05-22 23:16:28', NULL, 'add82dc118e2081500f19cbb321272d4', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index b0f3f118..b0df8605 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2495,3 +2495,4 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-144', 'task', 'T-132', 'Claude message card with brand-orange accent', 'Extend the distinct-card treatment (T-143) to Claude''s text responses with a different accent: Claude''s brand coral-orange (#D97757), vs the user card''s focus-colour stripe. Refactor _userCard into a shared _messageCard(label, accent, body). conversation_view.dart.', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-22 22:40:42', '2026-05-22 22:41:28', NULL, '4c0eebba4edb4394fb47ba2d81ef42ad', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-144', 'task', 'T-132', 'Claude message card with brand-orange accent', 'Extend the distinct-card treatment (T-143) to Claude''s text responses with a different accent: Claude''s brand coral-orange (#D97757), vs the user card''s focus-colour stripe. Refactor _userCard into a shared _messageCard(label, accent, body). conversation_view.dart.', 'done', 'low', NULL, NULL, NULL, '2026-05-22 22:40:42', '2026-05-22 22:43:45', NULL, '1ce891624631502279b0356c5cdffae2', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-139', 'task', 'T-132', 'tmux team observer (lifecycle + identity + per-teammate transcripts)', 'New lib/builtin/claude/src/team_observer.dart: detect teammate spawn/exit (control-mode or list-panes -a poll on -L clide), map pane->teammate via ~/.claude/teams config + agentId, spin a TranscriptReader per teammate, emit new TeamMemberBorn/TeamMemberDied ClideEvents (lib/kernel/src/events/types.dart). ALL fragile CC-internals parsing isolated here (single drift-containment point; key off the version field). Cite D-75. Acceptance: a real team run emits born/died with correct identity; per-teammate content streams. Blocked by T2, T4.', 'in_progress', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 22:50:41', NULL, 'a5f4b20a0a37332ab35de8fa07e22298', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-139', 'task', 'T-132', 'tmux team observer (lifecycle + identity + per-teammate transcripts)', 'New lib/builtin/claude/src/team_observer.dart: detect teammate spawn/exit (control-mode or list-panes -a poll on -L clide), map pane->teammate via ~/.claude/teams config + agentId, spin a TranscriptReader per teammate, emit new TeamMemberBorn/TeamMemberDied ClideEvents (lib/kernel/src/events/types.dart). ALL fragile CC-internals parsing isolated here (single drift-containment point; key off the version field). Cite D-75. Acceptance: a real team run emits born/died with correct identity; per-teammate content streams. Blocked by T2, T4.', 'done', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 23:16:28', NULL, 'bd5e9cc5ae0e8bdbac4ad8cc57b33b58', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/lib/builtin/claude/src/team_observer.dart b/lib/builtin/claude/src/team_observer.dart index 65c21d06..f50c81bf 100644 --- a/lib/builtin/claude/src/team_observer.dart +++ b/lib/builtin/claude/src/team_observer.dart @@ -10,7 +10,7 @@ /// `tmuxPaneId` (`%N`, empty for the lead), `name`, `agentType`, `model`, /// `color`, `cwd`, `joinedAt`. Polling `tmux -L clide list-panes -a` and /// correlating live pane ids with `tmuxPaneId` gives a dependable -/// born/died signal and full identity — no transcript needed. +/// joined/left signal and full identity — no transcript needed. /// - **Fragile — per-teammate transcript join.** A teammate's transcript is /// a subagent file `//subagents/agent-.jsonl` /// whose only ids are a random hex (the filename) and a `slug`; it carries @@ -137,8 +137,8 @@ class _LiveMember { final TranscriptPublisher? publisher; } -/// Watches a workspace's tmux team and emits [TeamMemberBorn] / -/// [TeamMemberDied] as panes appear/disappear, publishing each teammate's +/// Watches a workspace's tmux team and emits [TeamMemberJoined] / +/// [TeamMemberLeft] as panes appear/disappear, publishing each teammate's /// transcript onto the [MessageBus] under its per-agent channel. class TeamObserver { TeamObserver({ @@ -200,7 +200,7 @@ class TeamObserver { } /// One poll cycle (public for tests). Diffs the config roster against the - /// live panes and emits born/died. + /// live panes and emits joined/left. Future tick() async { final config = await discoverTeam(workspacePath, teamsBase: _teamsBase); if (config == null) { @@ -215,19 +215,19 @@ class TeamObserver { final paneLive = livePanes.contains(m.tmuxPaneId); final tracked = _live.containsKey(m.agentId); if (paneLive && !tracked) { - await _born(config, m); + await _joined(config, m); } else if (!paneLive && tracked) { - await _died(m.agentId); + await _left(m.agentId); } } - // A member dropped from the config (team reshaped) also counts as died. + // A member dropped from the config (team reshaped) also counts as left. for (final id in _live.keys.toList()) { - if (!configIds.contains(id)) await _died(id); + if (!configIds.contains(id)) await _left(id); } } - Future _born(TeamConfig config, TeamMember m) async { + Future _joined(TeamConfig config, TeamMember m) async { final path = await _resolveTranscript(config, m); TranscriptPublisher? pub; if (path != null) { @@ -238,7 +238,7 @@ class TeamObserver { ); } _live[m.agentId] = _LiveMember(m, config.team, pub); - _events.emit(TeamMemberBorn( + _events.emit(TeamMemberJoined( team: config.team, agentId: m.agentId, name: m.name, @@ -251,16 +251,16 @@ class TeamObserver { )); } - Future _died(String agentId) async { + Future _left(String agentId) async { final live = _live.remove(agentId); if (live == null) return; await live.publisher?.dispose(); - _events.emit(TeamMemberDied(team: live.team, agentId: agentId, paneId: live.member.tmuxPaneId)); + _events.emit(TeamMemberLeft(team: live.team, agentId: agentId, paneId: live.member.tmuxPaneId)); } Future _killAll() async { for (final id in _live.keys.toList()) { - await _died(id); + await _left(id); } } diff --git a/lib/kernel/src/events/types.dart b/lib/kernel/src/events/types.dart index 1c87e254..196be613 100644 --- a/lib/kernel/src/events/types.dart +++ b/lib/kernel/src/events/types.dart @@ -111,8 +111,8 @@ class DaemonEvent extends ClideEvent { /// taken from the team config, so it is reliable regardless of transcript /// drift (T-139, D-75). [transcriptPath] is the best-effort resolved /// subagent transcript, or null if it could not be joined yet. -class TeamMemberBorn extends ClideEvent { - const TeamMemberBorn({ +class TeamMemberJoined extends ClideEvent { + const TeamMemberJoined({ required this.team, required this.agentId, required this.name, @@ -142,7 +142,7 @@ class TeamMemberBorn extends ClideEvent { @override String get subsystem => 'team'; @override - String get kind => 'member-born'; + String get kind => 'member-joined'; @override Map payload() => { 'team': team, @@ -159,8 +159,8 @@ class TeamMemberBorn extends ClideEvent { /// A Claude Code tmux teammate's pane went away (it exited or the team /// dissolved) — T-139. -class TeamMemberDied extends ClideEvent { - const TeamMemberDied({ +class TeamMemberLeft extends ClideEvent { + const TeamMemberLeft({ required this.team, required this.agentId, required this.paneId, @@ -173,7 +173,7 @@ class TeamMemberDied extends ClideEvent { @override String get subsystem => 'team'; @override - String get kind => 'member-died'; + String get kind => 'member-left'; @override Map payload() => {'team': team, 'agentId': agentId, 'paneId': paneId}; } diff --git a/test/builtin/claude/team_observer_test.dart b/test/builtin/claude/team_observer_test.dart index 7410f676..80c10b7e 100644 --- a/test/builtin/claude/team_observer_test.dart +++ b/test/builtin/claude/team_observer_test.dart @@ -1,5 +1,5 @@ /// Tests for the tmux team observer (T-139). Pure Dart (no Flutter): -/// config parsing/discovery, the config-driven born/died lifecycle, and +/// config parsing/discovery, the config-driven joined/left lifecycle, and /// the best-effort subagent-transcript join — all exercised against /// on-disk fixtures, mirroring how the T-134 spike validated CC's /// undocumented team artifacts. @@ -54,8 +54,8 @@ Future _writeTeam(Directory teamsBase, String team, String json) asyn void main() { group('team events', () { - test('TeamMemberBorn payload carries identity + optional fields', () { - const e = TeamMemberBorn( + test('TeamMemberJoined payload carries identity + optional fields', () { + const e = TeamMemberJoined( team: 'myteam', agentId: 'alice@myteam', name: 'alice', @@ -67,7 +67,7 @@ void main() { transcriptPath: '/t/agent-a.jsonl', ); expect(e.subsystem, 'team'); - expect(e.kind, 'member-born'); + expect(e.kind, 'member-joined'); expect(e.payload(), { 'team': 'myteam', 'agentId': 'alice@myteam', @@ -81,15 +81,15 @@ void main() { }); }); - test('TeamMemberBorn omits null optional fields', () { - const e = TeamMemberBorn(team: 't', agentId: 'a@t', name: 'a', agentType: 'a', paneId: '%1'); + test('TeamMemberJoined omits null optional fields', () { + const e = TeamMemberJoined(team: 't', agentId: 'a@t', name: 'a', agentType: 'a', paneId: '%1'); expect(e.payload().keys, ['team', 'agentId', 'name', 'agentType', 'paneId']); }); - test('TeamMemberDied payload', () { - const e = TeamMemberDied(team: 't', agentId: 'a@t', paneId: '%1'); + test('TeamMemberLeft payload', () { + const e = TeamMemberLeft(team: 't', agentId: 'a@t', paneId: '%1'); expect(e.subsystem, 'team'); - expect(e.kind, 'member-died'); + expect(e.kind, 'member-left'); expect(e.payload(), {'team': 't', 'agentId': 'a@t', 'paneId': '%1'}); }); }); @@ -145,18 +145,18 @@ void main() { late Directory projectsBase; late DaemonBus events; late MessageBus messages; - late List born; - late List died; + late List joined; + late List left; setUp(() async { teamsBase = await Directory.systemTemp.createTemp('teams_'); projectsBase = await Directory.systemTemp.createTemp('projects_'); events = DaemonBus(); messages = MessageBus(); - born = []; - died = []; - events.on().listen(born.add); - events.on().listen(died.add); + joined = []; + left = []; + events.on().listen(joined.add); + events.on().listen(left.add); }); tearDown(() async { @@ -168,7 +168,7 @@ void main() { Future settle() => Future.delayed(const Duration(milliseconds: 10)); - test('emits born when a teammate pane is live, died when it goes', () async { + test('emits joined when a teammate pane is live, left when it goes', () async { await _writeTeam(teamsBase, 'myteam', _configJson(teammates: [_member('alice', '%5')])); var panes = {'%5'}; final obs = TeamObserver( @@ -183,21 +183,21 @@ void main() { await obs.tick(); await settle(); - expect(born.map((b) => b.name), ['alice']); - expect(born.single.paneId, '%5'); - expect(born.single.agentId, 'alice@myteam'); - expect(died, isEmpty); + expect(joined.map((b) => b.name), ['alice']); + expect(joined.single.paneId, '%5'); + expect(joined.single.agentId, 'alice@myteam'); + expect(left, isEmpty); - // Same pane still live -> no duplicate born. + // Same pane still live -> no duplicate joined. await obs.tick(); await settle(); - expect(born, hasLength(1)); + expect(joined, hasLength(1)); - // Pane gone -> died. + // Pane gone -> left. panes = {}; await obs.tick(); await settle(); - expect(died.map((d) => d.agentId), ['alice@myteam']); + expect(left.map((d) => d.agentId), ['alice@myteam']); }); test('start() polls on a timer and dispose() stops it', () async { @@ -212,16 +212,16 @@ void main() { pollInterval: const Duration(milliseconds: 20), ); obs.start(); - // Poll until the timer-driven tick emits born (or time out). + // Poll until the timer-driven tick emits joined (or time out). final deadline = DateTime.now().add(const Duration(seconds: 2)); - while (born.isEmpty && DateTime.now().isBefore(deadline)) { + while (joined.isEmpty && DateTime.now().isBefore(deadline)) { await Future.delayed(const Duration(milliseconds: 10)); } - expect(born.map((b) => b.name), ['alice']); + expect(joined.map((b) => b.name), ['alice']); await obs.dispose(); - // dispose emits died for the tracked member. + // dispose emits left for the tracked member. await settle(); - expect(died.map((d) => d.agentId), ['alice@myteam']); + expect(left.map((d) => d.agentId), ['alice@myteam']); }); test('constructs with default base dirs / pane lister', () { @@ -242,8 +242,8 @@ void main() { addTearDown(obs.dispose); await obs.tick(); await settle(); - expect(born, isEmpty); - expect(died, isEmpty); + expect(joined, isEmpty); + expect(left, isEmpty); }); test('joins the teammate transcript via a matching .meta.json', () async { @@ -266,7 +266,7 @@ void main() { await obs.tick(); await settle(); - expect(born.single.transcriptPath, endsWith('agent-aaa111.jsonl')); + expect(joined.single.transcriptPath, endsWith('agent-aaa111.jsonl')); }); test('falls back to joinedAt<->mtime order when no .meta.json', () async { @@ -300,7 +300,7 @@ void main() { await obs.tick(); await settle(); - final byName = {for (final b in born) b.name: b.transcriptPath}; + final byName = {for (final b in joined) b.name: b.transcriptPath}; expect(byName['first'], endsWith('agent-older.jsonl')); expect(byName['second'], endsWith('agent-newer.jsonl')); });