@warren-dean/code-kit 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -45,9 +45,19 @@ import {
45
45
  sendBackPane,
46
46
  toolLine,
47
47
  } from './panes.mjs';
48
- import { leadPrompt, nudgeText, restartPrompt, stallDue, stepKey, stepLabel } from './loop.mjs';
48
+ import {
49
+ leadPrompt,
50
+ nudgeText,
51
+ restartPrompt,
52
+ stallDue,
53
+ stepKey,
54
+ stepLabel,
55
+ storiesInFlight,
56
+ withoutInFlight,
57
+ } from './loop.mjs';
49
58
  import { goalOf, nextText } from './mission.mjs';
50
59
  import { contextOf, decisionCards } from './decisions.mjs';
60
+ import { DEFAULT_TEAM_UI, TEAM_ID, teamFrame } from './teamview.mjs';
51
61
  import { glowOf, mapStoryOf, touchOf } from './codemap.mjs';
52
62
  import { harnessSettings } from '../lib/harness.mjs';
53
63
  import {
@@ -67,7 +77,8 @@ let reviewTurn = null; // 'next' until the lead's review turn starts, then its i
67
77
  let approving = null; // the open Approve… confirmation: { request, reason, error }
68
78
  let result = null; // what the last merge reported
69
79
  let notice = null; // why the person's last act failed
70
- let teamSince = null; // TEAM-6: since you last looked, as read when the Team tab opened
80
+ let teamSince = null; // TEAM-6: since you last looked, as read when the Team pane's tab opened
81
+ let teamUi = { ...DEFAULT_TEAM_UI }; // TEAM-7: the Team pane's own state
71
82
  let boardSynced = -Infinity; // TEAM-12: when the board was last synced
72
83
  let projectRead = -Infinity; // MAP-5: when the whole project's map was last read
73
84
  const BOARD_EVERY_MS = 10 * 60000;
@@ -176,6 +187,14 @@ export function register(on) {
176
187
  immediate: true,
177
188
  })
178
189
  .catch(unavailable('harness'));
190
+ await $.command
191
+ .register({
192
+ name: 'team',
193
+ description:
194
+ "code-kit's team: who's at work on what, claims, intents, drift and the team's setup",
195
+ immediate: true,
196
+ })
197
+ .catch(unavailable('team'));
179
198
  rateLimits = (await $.session.usage().catch(() => null))?.rateLimits ?? [];
180
199
  const cli = `${$.plugin.root}/bin/code-kit.mjs`;
181
200
  const cwd = e.cwd ?? (await $.session.cwd());
@@ -267,8 +286,8 @@ export function register(on) {
267
286
  }
268
287
  }
269
288
  if (paneUi.tab === 'map') trailData = (await json('trail')) ?? trailData;
270
- // TEAM-6: since you last looked, read once each time the Team tab opens, which marks it read.
271
- if (paneUi.tab === 'team' && model.check?.team && !teamSince)
289
+ // TEAM-6: since you last looked, read once each time its tab opens, which marks it read.
290
+ if (teamUi.tab === 'activity' && model.check?.team && !teamSince)
272
291
  teamSince = (await json('team', 'since')) ?? { people: {} };
273
292
  // JOURNAL-1, DECIDE-2: the lineage, for the Journal tab and the decisions' cards.
274
293
  const deciding = held.size > 0 || (model.requests?.open ?? []).length > 0;
@@ -412,6 +431,8 @@ export function register(on) {
412
431
  // TRACE-3: the map follows the specs too.
413
432
  specs ? await listing(specs) : '',
414
433
  await stamp('.claude/state/requests.jsonl'),
434
+ // TEAM-7: a live provider (the hub's helper) rewrites the team's state as events arrive.
435
+ await stamp('.claude/state/team.json'),
415
436
  await stamp('.claude/state/merge-queue.json'),
416
437
  await listing('.claude/approvals'),
417
438
  await listing('.claude/state/stop-blocks'),
@@ -578,6 +599,22 @@ export function register(on) {
578
599
  await act.reload();
579
600
  },
580
601
  keepClaim: (line) => act.teamAct(['team', 'keep', line.claim.story], 'Keeping the claim'),
602
+ // TEAM-9: the tech lead hands a claim, with its lease, to someone else.
603
+ reassignTo: async (story, handle) => {
604
+ const to = handle.trim().replace(/^@/, '');
605
+ if (!to) return;
606
+ teamUi = { ...teamUi, reassign: null, handle: '' };
607
+ await act.teamAct(['team', 'reassign', story, to], `Reassigning ${story}`);
608
+ },
609
+ // TEAM-13: the team's setup is changed through its init, by the lead, with the person.
610
+ teamSetup: async () => {
611
+ await $.prompt
612
+ .submit({ text: "Run /code-kit:team-init: I want to change this project's team setup." })
613
+ .catch((err) => {
614
+ notice = `Couldn't ask the lead: ${err?.message ?? err}`;
615
+ });
616
+ $.ui.invalidate('ui.render');
617
+ },
581
618
  releaseClaim: (line) =>
582
619
  act.teamAct(['team', 'release', line.claim.story], 'Releasing the claim'),
583
620
  // TEAM-10: agreed, the lead records it where agents read it, through a pull request.
@@ -599,11 +636,43 @@ export function register(on) {
599
636
  ['intend', 'drop', String(line.intent.id), '--reason', 'dropped in the code-kit pane'],
600
637
  'Dropping the intent',
601
638
  ),
602
- // TEAM-7: the Team tab, opened from the band.
603
- team: async () => {
604
- paneUi = { ...paneUi, tab: 'team', story: null };
605
- if (!(await $.ui.panes()).some((p) => p.id === PANE_ID))
606
- await $.ui.open({ id: PANE_ID, title: 'Lanes', focus: true, closeOnEscape: true });
639
+ // TEAM-7: the Team pane, opened from the band (on what the line is about) or /team (which closes
640
+ // it when it's open).
641
+ team: async (line, { toggle = false } = {}) => {
642
+ const open = (await $.ui.panes()).some((p) => p.id === TEAM_ID);
643
+ if (open && toggle) {
644
+ await $.ui.close({ id: TEAM_ID });
645
+ return {};
646
+ }
647
+ if (toggle || !model.state) await act.reload();
648
+ if (model.state?.kind === 'none') return { text: NOT_CODE_KIT };
649
+ if (!model.check?.team)
650
+ return {
651
+ text: "This project isn't set up as a team: one developer, no claims or owners. To share it with a team, run /code-kit:team-init.",
652
+ };
653
+ const tab =
654
+ line?.kind === 'teamIntent'
655
+ ? 'intents'
656
+ : line?.kind === 'teamDrift'
657
+ ? 'drift'
658
+ : ['teamStale', 'teamCollision'].includes(line?.kind)
659
+ ? 'claims'
660
+ : line
661
+ ? 'people'
662
+ : teamUi.tab;
663
+ teamUi = { ...teamUi, tab };
664
+ // Opening it reads since-you-last-looked again, which marks it read.
665
+ if (tab !== 'activity' || !open) teamSince = null;
666
+ if (!open) {
667
+ const placed = await $.ui.open({
668
+ id: TEAM_ID,
669
+ title: 'Team',
670
+ focus: true,
671
+ closeOnEscape: true,
672
+ });
673
+ if (!placed?.isPlaced && placed?.reason)
674
+ return { text: `The Team pane is waiting: ${placed.reason}` };
675
+ }
607
676
  await act.readMaps();
608
677
  $.ui.invalidate('ui.render');
609
678
  return {};
@@ -730,7 +799,14 @@ export function register(on) {
730
799
  return act.submit(prompt, 'restart', story);
731
800
  }
732
801
  const ran = await $.process.run(['node', cli, 'next', '--json'], { cwd });
733
- const step = ran.exitCode === 0 ? parseJson(ran.stdout) : null;
802
+ // LOOP-9: never a story an agent is already at work on, building or reviewing it.
803
+ const step =
804
+ ran.exitCode === 0
805
+ ? withoutInFlight(
806
+ parseJson(ran.stdout),
807
+ storiesInFlight(await $.agent.list().catch(() => [])),
808
+ )
809
+ : null;
734
810
  // MISSION-1: the band shows what the loop takes next.
735
811
  loop.nextStep = step;
736
812
  if (!step) {
@@ -1089,7 +1165,7 @@ export function register(on) {
1089
1165
  await act.stalls();
1090
1166
  await act.animate();
1091
1167
  const now = await act.fingerprint();
1092
- const paneOpen = (await $.ui.panes()).some((p) => p.id === PANE_ID);
1168
+ const paneOpen = (await $.ui.panes()).some((p) => [PANE_ID, TEAM_ID].includes(p.id));
1093
1169
  quiet += 1;
1094
1170
  if (now === seen && quiet < (paneOpen ? 5 : 30)) return;
1095
1171
  quiet = 0;
@@ -1113,6 +1189,9 @@ export function register(on) {
1113
1189
  text: `code-kit is still starting; try /${name} again in a moment.`,
1114
1190
  });
1115
1191
  on('command.run', { command: 'lanes' }, async ($, e) => (act ? act.lanes() : starting('lanes')));
1192
+ on('command.run', { command: 'team' }, async ($, e) =>
1193
+ act ? act.team(null, { toggle: true }) : starting('team'),
1194
+ );
1116
1195
  on('command.run', { command: 'harness' }, async ($, e) =>
1117
1196
  act ? act.harness() : starting('harness'),
1118
1197
  );
@@ -1539,8 +1618,6 @@ export function register(on) {
1539
1618
  needs: linesNow(now).filter((l) => !l.mission),
1540
1619
  cards: cardsNow(now),
1541
1620
  journal: journalNow(),
1542
- team: model.team ?? null,
1543
- teamSince,
1544
1621
  now,
1545
1622
  live: liveNow(now),
1546
1623
  held: [...held.values()],
@@ -1567,7 +1644,6 @@ export function register(on) {
1567
1644
  $.ui.resolve(e),
1568
1645
  {
1569
1646
  onTab: (tab) => {
1570
- if (tab !== 'team') teamSince = null;
1571
1647
  paneUi = { ...paneUi, tab };
1572
1648
  $.ui.invalidate('ui.render');
1573
1649
  act?.readMaps().then(() => $.ui.invalidate('ui.render'));
@@ -1704,6 +1780,38 @@ export function register(on) {
1704
1780
  }),
1705
1781
  );
1706
1782
 
1783
+ // TEAM-7: the Team pane.
1784
+ on('ui.render', { component: 'Pane', requestId: TEAM_ID }, async ($, e) =>
1785
+ teamFrame(
1786
+ {
1787
+ team: model.team ?? null,
1788
+ since: teamSince,
1789
+ config: model.check?.team ? { ...model.check.team, lanes: model.check.lanes ?? {} } : null,
1790
+ ui: teamUi,
1791
+ },
1792
+ $.ui.resolve(e),
1793
+ {
1794
+ onTab: (tab) => {
1795
+ if (tab !== 'activity') teamSince = null;
1796
+ teamUi = { ...teamUi, tab, reassign: null, handle: '' };
1797
+ $.ui.invalidate('ui.render');
1798
+ act?.readMaps().then(() => $.ui.invalidate('ui.render'));
1799
+ },
1800
+ onNeed: (id, line) => act?.[id]?.(line),
1801
+ onReassign: (story) => {
1802
+ teamUi = { ...teamUi, reassign: story, handle: '' };
1803
+ $.ui.invalidate('ui.render');
1804
+ },
1805
+ onHandle: (handle) => {
1806
+ teamUi = { ...teamUi, handle };
1807
+ $.ui.invalidate('ui.render');
1808
+ },
1809
+ onReassignTo: (story, handle) => act?.reassignTo(story, handle),
1810
+ onSetup: () => act?.teamSetup(),
1811
+ },
1812
+ ),
1813
+ );
1814
+
1707
1815
  on('ui.render', { component: 'Pane', requestId: RESULT_ID }, async ($, e) =>
1708
1816
  resultPane(result, $.ui.resolve(e)),
1709
1817
  );
@@ -8,6 +8,7 @@ const PANE = 'code-kit-lanes';
8
8
  const APPROVE = 'code-kit-approve';
9
9
  const RESULT = 'code-kit-result';
10
10
  const SETTINGS = 'code-kit-settings';
11
+ const TEAM = 'code-kit-team';
11
12
 
12
13
  /** A project as the stubs report it; tests change it to move the world on. */
13
14
  function project() {
@@ -200,7 +201,7 @@ function stub(on: any, w: World) {
200
201
  w.acts.push([...argv.slice(2)]);
201
202
  return ran(0, JSON.stringify(w.teamSince));
202
203
  }
203
- if (sub === 'team' && ['keep', 'release', 'board'].includes(argv[3])) {
204
+ if (sub === 'team' && ['keep', 'release', 'board', 'reassign'].includes(argv[3])) {
204
205
  w.acts.push([...argv.slice(2)]);
205
206
  return ran(0, '');
206
207
  }
@@ -430,6 +431,7 @@ const bandUi = ($: any) =>
430
431
  const press = ($: any, key: string, requestId?: string) =>
431
432
  $.ui.press({ plugin: 'code-kit', key, ...(requestId ? { requestId } : {}) });
432
433
  const lanes = ($: any) => $.command.run({ command: 'lanes', args: '' });
434
+ const teamCmd = ($: any) => $.command.run({ command: 'team', args: '' });
433
435
  const dayjs = {
434
436
  at: '2026-10-06T00:00:00.000Z',
435
437
  actor: 'web-engineer',
@@ -1525,6 +1527,34 @@ test('LOOP-1 and LOOP-2 with the lead idle, the loop dispatches every ready stor
1525
1527
  await ui.unmount();
1526
1528
  });
1527
1529
 
1530
+ test('LOOP-9 the loop never asks for a story an agent is still on: it waits, and goes on once the agent finishes', async ($, on) => {
1531
+ const w = project();
1532
+ w.next = { step: 'dispatch', args: 'ST-9', then: [] };
1533
+ const clock = await start($, on, w);
1534
+ await leadTurn($);
1535
+ await settle(clock);
1536
+ expect(w.prompts).toEqual(['Dispatch ST-9: run /code-kit:dispatch ST-9.']);
1537
+ // Dispatched, and ST-4 still being built: next (reading only branches) offers both again.
1538
+ w.agents = [
1539
+ { id: 'a1', type: 'web-engineer', status: 'running', description: 'ST-4 booking form' },
1540
+ { id: 'a2', type: 'core-engineer', status: 'running', description: 'ST-9 pricing' },
1541
+ ];
1542
+ w.next = { step: 'review', args: 'ST-4', then: [{ step: 'dispatch', args: 'ST-9' }] };
1543
+ await leadTurn($, 't-2');
1544
+ await settle(clock);
1545
+ expect(w.prompts).toHaveLength(1);
1546
+ const band = await bandUi($);
1547
+ expect(
1548
+ await band.find({ type: 'Text', text: /waiting: ST-4, ST-9 are still with their agents/ }),
1549
+ ).toBeDefined();
1550
+ await band.unmount();
1551
+ // The web agent finishes: ST-4 is reviewed; ST-9 is still being built, so it isn't dispatched again.
1552
+ w.agents = [{ ...w.agents[0], status: 'completed' }, w.agents[1]];
1553
+ await clock.advance(2000);
1554
+ await settle(clock);
1555
+ expect(w.prompts.slice(1)).toEqual(['Review ST-4: run /code-kit:review ST-4.']);
1556
+ });
1557
+
1528
1558
  test('LOOP-1 nothing is submitted mid-turn, or while the person has a draft, or twice for the same step', async ($, on) => {
1529
1559
  const w = project();
1530
1560
  const clock = await start($, on, w);
@@ -3017,7 +3047,32 @@ test("TEAM-8 when the shared repository can't be reached the band says so as a n
3017
3047
  await band.unmount();
3018
3048
  });
3019
3049
 
3020
- test('TEAM-6 and TEAM-7 the Team tab: since you last looked, then each person with their lanes, claims, pulls and intents', async ($, on) => {
3050
+ test('TEAM-7 /team opens the Team pane on its people: each with their lanes, claims, pulls and intents; /team again closes it', async ($, on) => {
3051
+ const w = project();
3052
+ w.team = teamOf('sam');
3053
+ (w.check as any).team = { techLead: 'alex' };
3054
+ await start($, on, w);
3055
+ await teamCmd($);
3056
+ expect(w.opened).toEqual([TEAM]);
3057
+ const ui = await pane($, TEAM);
3058
+ expect((await ui.find({ key: 'team-header' }))?.text).toMatch(
3059
+ /tech lead alex[\s\S]*you are sam · own web[\s\S]*2 claimed · 1 intent open · 1 pull request/,
3060
+ );
3061
+ expect((await ui.find({ key: 'team-person-alex' }))?.text).toMatch(/alex · tech lead/);
3062
+ expect((await ui.find({ key: 'team-person-sam' }))?.text).toMatch(
3063
+ /sam · you · owns web[\s\S]*ST-4 · stale since Tuesday/,
3064
+ );
3065
+ expect((await ui.find({ key: 'team-person-kim' }))?.text).toMatch(
3066
+ /#14 Booking list \(web\/st-9\)[\s\S]*intends: Split the booking form/,
3067
+ );
3068
+ // GitHub's own provider has no drift checks, so no Drift tab.
3069
+ expect(await ui.find({ key: 'team-tab-drift' })).toBeUndefined();
3070
+ await ui.unmount();
3071
+ await teamCmd($);
3072
+ expect(w.closed).toEqual([TEAM]);
3073
+ });
3074
+
3075
+ test('TEAM-6 since you last looked is read when its tab opens, which marks it read, and again each time it opens', async ($, on) => {
3021
3076
  const w = project();
3022
3077
  w.team = teamOf('sam');
3023
3078
  w.teamSince = {
@@ -3027,35 +3082,248 @@ test('TEAM-6 and TEAM-7 the Team tab: since you last looked, then each person wi
3027
3082
  };
3028
3083
  (w.check as any).team = { techLead: 'alex' };
3029
3084
  await start($, on, w);
3030
- await lanes($);
3031
- const ui = await pane($, PANE);
3032
- expect((await ui.find({ key: 'tab-team' }))?.props.hotkey).toBe('8');
3033
- await press($, 'tab-team', PANE);
3085
+ await teamCmd($);
3086
+ const ui = await pane($, TEAM);
3087
+ const reads = () => w.acts.filter((a) => a[0] === 'team' && a[1] === 'since').length;
3088
+ expect(reads()).toBe(0);
3089
+ expect((await ui.find({ key: 'team-tab-activity' }))?.props.hotkey).toBe('2');
3090
+ await press($, 'team-tab-activity', TEAM);
3034
3091
  expect(w.acts).toContainEqual(['team', 'since', '--json']);
3035
3092
  expect((await ui.find({ key: 'team-since' }))?.text).toMatch(
3036
- /kim: pushed 2 commits to web\/st-9/,
3093
+ /kim[\s\S]*pushed 2 commits to web\/st-9/,
3037
3094
  );
3038
- expect((await ui.find({ key: 'team-person-alex' }))?.text).toMatch(/alex · tech lead/);
3039
- expect((await ui.find({ key: 'team-person-sam' }))?.text).toMatch(
3040
- /sam · you · owns web[\s\S]*ST-4 · stale since Tuesday/,
3041
- );
3042
- expect((await ui.find({ key: 'team-person-kim' }))?.text).toMatch(
3043
- /#14 Booking list \(web\/st-9\)[\s\S]*intends: Split the booking form/,
3095
+ await press($, 'team-tab-people', TEAM);
3096
+ await press($, 'team-tab-activity', TEAM);
3097
+ expect(reads()).toBe(2);
3098
+ await ui.unmount();
3099
+ });
3100
+
3101
+ test("TEAM-9 the Claims tab: Keep and Release on yours, and nothing on others'", async ($, on) => {
3102
+ const w = project();
3103
+ w.team = teamOf('sam');
3104
+ (w.check as any).team = { techLead: 'alex' };
3105
+ await start($, on, w);
3106
+ await teamCmd($);
3107
+ const ui = await pane($, TEAM);
3108
+ await press($, 'team-tab-claims', TEAM);
3109
+ expect((await ui.find({ key: 'team-claims-ST-4' }))?.text).toMatch(
3110
+ /ST-4[\s\S]*sam \(you\)[\s\S]*stale since Tuesday[\s\S]*leases apps\/web\/booking\/\*\*/,
3044
3111
  );
3045
- await press($, 'team-release-ST-4', PANE);
3112
+ expect(await ui.find({ key: 'team-release-ST-9' })).toBeUndefined();
3113
+ expect(await ui.find({ key: 'team-reassign-ST-4' })).toBeUndefined();
3114
+ await press($, 'team-keep-ST-4', TEAM);
3115
+ expect(w.acts).toContainEqual(['team', 'keep', 'ST-4']);
3116
+ await press($, 'team-release-ST-4', TEAM);
3046
3117
  expect(w.acts).toContainEqual(['team', 'release', 'ST-4']);
3047
3118
  await ui.unmount();
3048
3119
  });
3049
3120
 
3050
- test('TEAM-7 without a team there is no Team tab', async ($, on) => {
3121
+ test("TEAM-9 the tech lead releases or reassigns anyone's claim from the Claims tab", async ($, on) => {
3122
+ const w = project();
3123
+ w.team = teamOf('alex');
3124
+ (w.check as any).team = { techLead: 'alex' };
3125
+ await start($, on, w);
3126
+ await teamCmd($);
3127
+ const ui = await pane($, TEAM);
3128
+ await press($, 'team-tab-claims', TEAM);
3129
+ await press($, 'team-release-ST-9', TEAM);
3130
+ expect(w.acts).toContainEqual(['team', 'release', 'ST-9']);
3131
+ expect(await ui.find({ key: 'team-reassign-to-ST-9' })).toBeUndefined();
3132
+ await press($, 'team-reassign-ST-9', TEAM);
3133
+ expect(await ui.find({ key: 'team-reassign-to-ST-9' })).toBeDefined();
3134
+ await $.ui.input({ plugin: 'code-kit', key: 'team-reassign-to-ST-9', text: '@sam' });
3135
+ expect(w.acts).toContainEqual(['team', 'reassign', 'ST-9', 'sam']);
3136
+ expect(await ui.find({ key: 'team-reassign-to-ST-9' })).toBeUndefined();
3137
+ await ui.unmount();
3138
+ });
3139
+
3140
+ test('TEAM-5 the Intents tab lists the open intents, with Agree and Drop for the tech lead', async ($, on) => {
3141
+ const w = project();
3142
+ w.team = teamOf('alex');
3143
+ (w.check as any).team = { techLead: 'alex' };
3144
+ await start($, on, w);
3145
+ await teamCmd($);
3146
+ const ui = await pane($, TEAM);
3147
+ await press($, 'team-tab-intents', TEAM);
3148
+ expect((await ui.find({ key: 'team-intents-12' }))?.text).toMatch(
3149
+ /#12[\s\S]*Split the booking form[\s\S]*kim[\s\S]*touches apps\/web\/\*\*/,
3150
+ );
3151
+ await press($, 'team-drop-12', TEAM);
3152
+ expect(w.acts).toContainEqual([
3153
+ 'intend',
3154
+ 'drop',
3155
+ '12',
3156
+ '--reason',
3157
+ 'dropped in the code-kit pane',
3158
+ ]);
3159
+ await ui.unmount();
3160
+ });
3161
+
3162
+ test('TEAM-5 a developer sees the intents without Agree or Drop', async ($, on) => {
3163
+ const w = project();
3164
+ w.team = teamOf('sam');
3165
+ (w.check as any).team = { techLead: 'alex' };
3166
+ await start($, on, w);
3167
+ await teamCmd($);
3168
+ const ui = await pane($, TEAM);
3169
+ await press($, 'team-tab-intents', TEAM);
3170
+ expect(await ui.find({ key: 'team-intents-12' })).toBeDefined();
3171
+ expect(await ui.find({ key: 'team-agree-12' })).toBeUndefined();
3172
+ await ui.unmount();
3173
+ });
3174
+
3175
+ test("TEAM-13 the Setup tab shows the team's config and what's still missing, and asks the lead to change it", async ($, on) => {
3176
+ const w = project();
3177
+ w.team = {
3178
+ ...teamOf('sam'),
3179
+ provider: {
3180
+ name: 'GitHub',
3181
+ live: false,
3182
+ detail: 'claims are refs on origin, intents are discussions in "Intents"',
3183
+ },
3184
+ setup: [
3185
+ 'Discussions are off for this repository. Turn them on in its Settings › Features, add a "Intents" category.',
3186
+ ],
3187
+ };
3188
+ (w.check as any).team = {
3189
+ techLead: 'alex',
3190
+ staleDays: 3,
3191
+ intents: 'Intents',
3192
+ provider: 'github',
3193
+ owners: { web: ['sam'] },
3194
+ };
3195
+ await start($, on, w);
3196
+ await teamCmd($);
3197
+ const ui = await pane($, TEAM);
3198
+ expect((await ui.find({ key: 'team-provider' }))?.text).toBe(
3199
+ 'Through GitHub: claims are refs on origin, intents are discussions in "Intents"',
3200
+ );
3201
+ expect((await ui.find({ key: 'team-setup-count' }))?.text).toBe(
3202
+ '1 thing to set up: see Setup (6).',
3203
+ );
3204
+ await press($, 'team-tab-setup', TEAM);
3205
+ expect((await ui.find({ key: 'team-config' }))?.text).toMatch(
3206
+ /Tech lead\s+alex[\s\S]*Provider\s+GitHub[\s\S]*Lane web\s+sam[\s\S]*Unowned lanes\s+api, core/,
3207
+ );
3208
+ expect((await ui.find({ key: 'team-todo' }))?.text).toMatch(/Discussions are off/);
3209
+ await press($, 'team-setup', TEAM);
3210
+ expect(w.prompts.at(-1)).toMatch(/\/code-kit:team-init/);
3211
+ await ui.unmount();
3212
+ });
3213
+
3214
+ test('TEAM-13 without a team, /team says how to set one up, and opens nothing', async ($, on) => {
3051
3215
  const w = project();
3052
3216
  await start($, on, w);
3217
+ const said = await teamCmd($);
3218
+ expect(said.text).toMatch(/isn't set up as a team[\s\S]*\/code-kit:team-init/);
3219
+ expect(w.opened).toEqual([]);
3220
+ });
3221
+
3222
+ test('TEAM-7 the Lanes pane has no Team tab: the team has its own pane', async ($, on) => {
3223
+ const w = project();
3224
+ w.team = teamOf('sam');
3225
+ (w.check as any).team = { techLead: 'alex' };
3226
+ await start($, on, w);
3053
3227
  await lanes($);
3054
3228
  const ui = await pane($, PANE);
3055
3229
  expect(await ui.find({ key: 'tab-team' })).toBeUndefined();
3056
3230
  await ui.unmount();
3057
3231
  });
3058
3232
 
3233
+ test('a live provider says so in the Team pane, by its own name, and has a Drift tab', async ($, on) => {
3234
+ const w = project();
3235
+ w.team = {
3236
+ ...teamOf('kim'),
3237
+ provider: {
3238
+ name: 'the code-kit teams hub',
3239
+ live: true,
3240
+ account: 'kim',
3241
+ detail: 'claims, leases and intents as they happen',
3242
+ },
3243
+ findings: [],
3244
+ };
3245
+ (w.check as any).team = { techLead: 'alex' };
3246
+ await start($, on, w);
3247
+ await teamCmd($);
3248
+ const ui = await pane($, TEAM);
3249
+ expect((await ui.find({ key: 'team-provider' }))?.text).toBe(
3250
+ 'Through the code-kit teams hub · live · signed in as kim: claims, leases and intents as they happen',
3251
+ );
3252
+ await press($, 'team-tab-drift', TEAM);
3253
+ expect(
3254
+ await ui.find({ type: 'Text', text: 'No drift found on the branches at work.' }),
3255
+ ).toBeDefined();
3256
+ await ui.unmount();
3257
+ });
3258
+
3259
+ test("with a live provider (the hub), the band says when it fell back to GitHub, and names drift on your work; the Team tab shows who's at work and the drift found", async ($, on) => {
3260
+ const w = project();
3261
+ const at = new Date().toISOString();
3262
+ w.team = {
3263
+ ...teamOf('kim'),
3264
+ fallback: "The code-kit teams hub can't be reached.",
3265
+ sessions: [
3266
+ {
3267
+ login: 'kim',
3268
+ branch: 'web/st-9',
3269
+ agents: [{ type: 'web-engineer', story: 'ST-9', tool: 'Edit' }],
3270
+ seen: at,
3271
+ away: false,
3272
+ },
3273
+ { login: 'sam', branch: 'api/st-4', agents: [], seen: at, away: true },
3274
+ ],
3275
+ findings: [
3276
+ {
3277
+ id: 1,
3278
+ branch: 'web/st-9',
3279
+ by: 'kim',
3280
+ conflictsWith: 'spec docs/specs/02-booking.md',
3281
+ severity: 'blocker',
3282
+ text: 'BOOK-2 now holds prices in cents.',
3283
+ at,
3284
+ },
3285
+ {
3286
+ id: 2,
3287
+ branch: 'api/st-4',
3288
+ by: 'sam',
3289
+ conflictsWith: 'branch web/st-9',
3290
+ severity: 'note',
3291
+ text: 'only a note',
3292
+ at,
3293
+ },
3294
+ ],
3295
+ };
3296
+ (w.check as any).team = { techLead: 'alex' };
3297
+ await start($, on, w);
3298
+ const band = await bandUi($);
3299
+ expect(
3300
+ await band.find({
3301
+ type: 'Text',
3302
+ text: "team: the team's provider can't be reached, so claims are checked through GitHub until it's back",
3303
+ }),
3304
+ ).toBeDefined();
3305
+ expect(
3306
+ await band.find({ type: 'Text', text: 'drift on web/st-9: BOOK-2 now holds prices in cents.' }),
3307
+ ).toBeDefined();
3308
+ expect(await band.find({ type: 'Text', text: /only a note/ })).toBeUndefined();
3309
+ // The drift line's Team button (the second: the collision's comes first) opens the Team pane on the drift.
3310
+ await press($, 'band-team-2', 'above-prompt');
3311
+ expect(w.opened).toEqual([TEAM]);
3312
+ const ui = await pane($, TEAM);
3313
+ expect((await ui.find({ key: 'team-provider' }))?.text).toMatch(
3314
+ /can't be reached \(The code-kit teams hub can't be reached\.\): claims are checked through GitHub/,
3315
+ );
3316
+ expect((await ui.find({ key: 'team-drift-web/st-9' }))?.text).toMatch(
3317
+ /web\/st-9 · kim[\s\S]*blocker · spec docs\/specs\/02-booking\.md: BOOK-2 now holds prices in cents\./,
3318
+ );
3319
+ await press($, 'team-tab-people', TEAM);
3320
+ expect((await ui.find({ key: 'team-person-kim' }))?.text).toMatch(
3321
+ /at work on web\/st-9: web-engineer on ST-9 \(Edit\)[\s\S]*drift on web\/st-9 \(blocker\): BOOK-2 now holds prices in cents\./,
3322
+ );
3323
+ expect((await ui.find({ key: 'team-person-sam' }))?.text).toMatch(/away \(last seen/);
3324
+ await ui.unmount();
3325
+ });
3326
+
3059
3327
  test('TEAM-12 with a board, the mod syncs it in the background, at most every 10 minutes', async ($, on) => {
3060
3328
  const w = project();
3061
3329
  w.team = teamOf('sam');