@warren-dean/code-kit 0.8.0 → 0.9.0

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.
@@ -154,6 +154,8 @@ function project() {
154
154
  map: null as any, // `code-kit map <story> --json`
155
155
  trail: null as any, // `code-kit trail --json`
156
156
  journal: { events: [] as any[] }, // `code-kit journal --json`
157
+ team: null as any, // `code-kit team --json`, for a team's project
158
+ teamSince: { people: {} } as any, // `code-kit team since --json`
157
159
  holdWaits: false, // false: the hold ends at once, as if timed out (tests about cards, not hold)
158
160
  waiting: new Map<string, (answer: string) => void>(),
159
161
  };
@@ -194,6 +196,19 @@ function stub(on: any, w: World) {
194
196
  if (sub === 'map') return ran(w.map ? 0 : 1, JSON.stringify(w.map));
195
197
  if (sub === 'trail') return ran(w.trail ? 0 : 1, JSON.stringify(w.trail));
196
198
  if (sub === 'journal') return ran(0, JSON.stringify(w.journal));
199
+ if (sub === 'team' && argv[3] === 'since') {
200
+ w.acts.push([...argv.slice(2)]);
201
+ return ran(0, JSON.stringify(w.teamSince));
202
+ }
203
+ if (sub === 'team' && ['keep', 'release', 'board'].includes(argv[3])) {
204
+ w.acts.push([...argv.slice(2)]);
205
+ return ran(0, '');
206
+ }
207
+ if (sub === 'team') return ran(w.team ? 0 : 1, JSON.stringify(w.team));
208
+ if (sub === 'intend') {
209
+ w.acts.push([...argv.slice(2)]);
210
+ return ran(0, '');
211
+ }
197
212
  if (sub === 'loop' && ['pause', 'resume'].includes(argv[3])) {
198
213
  w.acts.push([...argv.slice(2)]);
199
214
  w.loopPaused = argv[3] === 'pause';
@@ -2066,18 +2081,31 @@ test('MQ-2 where the person merges, the head of the queue waits in the band for
2066
2081
  expect(w.opened).toContain(RESULT);
2067
2082
  });
2068
2083
 
2069
- test('MQ-2 the first merge, before the base has code-kit rules committed, is offered to the person', async ($, on) => {
2084
+ test("SETUP-2 with no rules on the base yet, the lead puts them there under autonomy: the band doesn't ask the person to merge", async ($, on) => {
2070
2085
  const w = project();
2071
2086
  (w.check as any).firstMerge = true;
2072
2087
  w.queue = [{ branch: 'lead/st-1', passed: '2026-10-10T00:00:00Z', state: 'waiting' }];
2088
+ w.next = { step: 'rules', args: 'main', then: [] };
2073
2089
  await start($, on, w);
2074
2090
  const ui = await bandUi($);
2075
- expect(
2076
- await ui.find({
2077
- type: 'Text',
2078
- text: 'lead/st-1 is next to merge: the first merge is yours, then the lead merges',
2079
- }),
2080
- ).toBeDefined();
2091
+ expect(await ui.find({ type: 'Text', text: /next to merge/ })).toBeUndefined();
2092
+ await ui.unmount();
2093
+ });
2094
+
2095
+ test("SETUP-2 with the loop off, the merge is the person's, and its card says how the rules get onto main", async ($, on) => {
2096
+ const w = project();
2097
+ (w.check as any).firstMerge = true;
2098
+ (w.check as any).harness = { autonomy: 'off' };
2099
+ w.queue = [{ branch: 'lead/st-1', passed: '2026-10-10T00:00:00Z', state: 'waiting' }];
2100
+ await start($, on, w);
2101
+ const band = await bandUi($);
2102
+ expect(await band.find({ type: 'Text', text: 'lead/st-1 is next to merge' })).toBeDefined();
2103
+ await press($, 'band-decide');
2104
+ await band.unmount();
2105
+ const ui = await pane($, PANE);
2106
+ expect((await ui.find({ key: 'decision-card' }))?.text).toMatch(
2107
+ /code-kit queue rules puts them there/,
2108
+ );
2081
2109
  await ui.unmount();
2082
2110
  });
2083
2111
 
@@ -2452,13 +2480,18 @@ test('TRACE-2 selecting a cell, by keys or the list, shows its spec, stories wit
2452
2480
  expect(JSON.stringify((await ui.find({ key: 'cell-BOOK-21' }))?.children)).toMatch(
2453
2481
  /"inverse":true/,
2454
2482
  );
2483
+ // The list holds the selected requirement's spec: payments' twenty, none of booking's.
2484
+ const options = (await ui.find({ key: 'map-select' }))?.props.options ?? [];
2485
+ expect(options.length).toBe(20);
2486
+ expect(options.every((o: any) => Number(o.value.split('-')[1]) > 20)).toBe(true);
2487
+ await $.ui.select({ plugin: 'code-kit', key: 'map-select', value: 'BOOK-40', requestId: PANE });
2488
+ expect((await ui.find({ key: 'map-detail' }))?.text).toMatch(/No story delivers it yet/);
2489
+ await press($, 'row-prev', PANE);
2455
2490
  await $.ui.select({ plugin: 'code-kit', key: 'map-select', value: 'BOOK-3', requestId: PANE });
2456
2491
  const detail = (await ui.find({ key: 'map-detail' }))?.text ?? '';
2457
2492
  expect(detail).toMatch(/docs\/spec\/01-booking.md · done and tested/);
2458
2493
  expect(detail).toMatch(/ST-4 Booking form · web · in progress · web\/st-4/);
2459
2494
  expect(detail).toMatch(/apps\/web\/b3.test.ts/);
2460
- await $.ui.select({ plugin: 'code-kit', key: 'map-select', value: 'BOOK-40', requestId: PANE });
2461
- expect((await ui.find({ key: 'map-detail' }))?.text).toMatch(/No story delivers it yet/);
2462
2495
  await ui.unmount();
2463
2496
  });
2464
2497
 
@@ -2866,3 +2899,212 @@ test('the lead reads orchestrating while its turn runs, and idle once it ends; a
2866
2899
  expect((await ui.find({ key: 'lane-lead' }))?.text).toMatch(/lead\s*idle/);
2867
2900
  await ui.unmount();
2868
2901
  });
2902
+
2903
+ // --- teams (spec 16) ---------------------------------------------------------------------------------
2904
+
2905
+ /** A team's state as `code-kit team --json` reports it, seen by `me`. */
2906
+ function teamOf(me: string) {
2907
+ const at = '2026-10-06T09:00:00.000Z'; // a Tuesday
2908
+ return {
2909
+ at,
2910
+ reachable: true,
2911
+ github: true,
2912
+ me,
2913
+ techLead: 'alex',
2914
+ owners: { web: ['sam'] },
2915
+ staleDays: 3,
2916
+ claims: [
2917
+ { story: 'ST-4', by: 'sam', at, active: at, stale: true, lease: ['apps/web/booking/**'] },
2918
+ {
2919
+ story: 'ST-9',
2920
+ by: 'kim',
2921
+ at,
2922
+ active: at,
2923
+ stale: false,
2924
+ lease: ['apps/web/booking/form.tsx'],
2925
+ },
2926
+ ],
2927
+ intents: [
2928
+ {
2929
+ id: 12,
2930
+ by: 'kim',
2931
+ about: 'Split the booking form',
2932
+ touches: ['apps/web/**'],
2933
+ at,
2934
+ url: 'u',
2935
+ state: 'open',
2936
+ },
2937
+ ],
2938
+ pulls: [
2939
+ {
2940
+ number: 14,
2941
+ title: 'Booking list',
2942
+ by: 'kim',
2943
+ branch: 'web/st-9',
2944
+ draft: false,
2945
+ updated: at,
2946
+ url: 'u',
2947
+ },
2948
+ ],
2949
+ };
2950
+ }
2951
+
2952
+ test('TEAM-7 and TEAM-9 the band names what of the team needs you: your stale claim, a collision, an intent touching yours, a review in your lane', async ($, on) => {
2953
+ const w = project();
2954
+ w.team = teamOf('sam');
2955
+ (w.check as any).team = { techLead: 'alex' };
2956
+ (w.check as any).lanes.web.paths = ['apps/web/**'];
2957
+ await start($, on, w);
2958
+ const band = await bandUi($);
2959
+ expect(
2960
+ await band.find({ type: 'Text', text: 'Still on ST-4? Nothing on it since Tuesday' }),
2961
+ ).toBeDefined();
2962
+ expect(
2963
+ await band.find({ type: 'Text', text: "ST-4 and kim's ST-9 both change apps/web/booking/**" }),
2964
+ ).toBeDefined();
2965
+ expect(
2966
+ await band.find({ type: 'Text', text: 'kim intends: Split the booking form (touches yours)' }),
2967
+ ).toBeDefined();
2968
+ expect(
2969
+ await band.find({
2970
+ type: 'Text',
2971
+ text: "kim's #14 Booking list is in your web lane and waits for your review",
2972
+ }),
2973
+ ).toBeDefined();
2974
+ await press($, 'band-keepClaim');
2975
+ expect(w.acts).toContainEqual(['team', 'keep', 'ST-4']);
2976
+ await band.unmount();
2977
+ });
2978
+
2979
+ test('TEAM-5 the tech lead sees every open intent with Agree and Drop, which act as them', async ($, on) => {
2980
+ const w = project();
2981
+ w.team = teamOf('alex');
2982
+ (w.check as any).team = { techLead: 'alex' };
2983
+ await start($, on, w);
2984
+ const band = await bandUi($);
2985
+ expect(
2986
+ await band.find({ type: 'Text', text: 'kim intends: Split the booking form' }),
2987
+ ).toBeDefined();
2988
+ expect(await band.find({ type: 'Text', text: /Still on/ })).toBeUndefined();
2989
+ await press($, 'band-agreeIntent');
2990
+ expect(w.acts).toContainEqual([
2991
+ 'intend',
2992
+ 'agree',
2993
+ '12',
2994
+ '--reason',
2995
+ 'agreed in the code-kit pane',
2996
+ ]);
2997
+ // TEAM-10: then the lead records it where agents read it, through a pull request.
2998
+ expect(
2999
+ w.prompts.some((p) => p.includes('intend record 12') && p.includes('lead/decision-12')),
3000
+ ).toBe(true);
3001
+ await band.unmount();
3002
+ });
3003
+
3004
+ test("TEAM-8 when the shared repository can't be reached the band says so as a note", async ($, on) => {
3005
+ const w = project();
3006
+ w.team = { ...teamOf('sam'), reachable: false, claims: [], intents: [], pulls: [] };
3007
+ (w.check as any).team = { techLead: 'alex' };
3008
+ await start($, on, w);
3009
+ const band = await bandUi($);
3010
+ expect(
3011
+ await band.find({
3012
+ type: 'Text',
3013
+ text: "team: the shared repository can't be reached, so claims aren't checked",
3014
+ }),
3015
+ ).toBeDefined();
3016
+ expect(await band.find({ type: 'Text', text: 'note' })).toBeDefined();
3017
+ await band.unmount();
3018
+ });
3019
+
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) => {
3021
+ const w = project();
3022
+ w.team = teamOf('sam');
3023
+ w.teamSince = {
3024
+ since: '2026-10-10T00:00:00Z',
3025
+ github: true,
3026
+ people: { kim: [{ at: 1, by: 'kim', kind: 'pushed', text: 'pushed 2 commits to web/st-9' }] },
3027
+ };
3028
+ (w.check as any).team = { techLead: 'alex' };
3029
+ 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);
3034
+ expect(w.acts).toContainEqual(['team', 'since', '--json']);
3035
+ expect((await ui.find({ key: 'team-since' }))?.text).toMatch(
3036
+ /kim: pushed 2 commits to web\/st-9/,
3037
+ );
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/,
3044
+ );
3045
+ await press($, 'team-release-ST-4', PANE);
3046
+ expect(w.acts).toContainEqual(['team', 'release', 'ST-4']);
3047
+ await ui.unmount();
3048
+ });
3049
+
3050
+ test('TEAM-7 without a team there is no Team tab', async ($, on) => {
3051
+ const w = project();
3052
+ await start($, on, w);
3053
+ await lanes($);
3054
+ const ui = await pane($, PANE);
3055
+ expect(await ui.find({ key: 'tab-team' })).toBeUndefined();
3056
+ await ui.unmount();
3057
+ });
3058
+
3059
+ test('TEAM-12 with a board, the mod syncs it in the background, at most every 10 minutes', async ($, on) => {
3060
+ const w = project();
3061
+ w.team = teamOf('sam');
3062
+ (w.check as any).team = { techLead: 'alex', board: { owner: 'o', number: 3 } };
3063
+ const clock = await start($, on, w);
3064
+ const syncs = () => w.acts.filter((a) => a[0] === 'team' && a[1] === 'board').length;
3065
+ expect(syncs()).toBe(1);
3066
+ await clock.advance(60000);
3067
+ expect(syncs()).toBe(1);
3068
+ await clock.advance(10 * 60000);
3069
+ expect(syncs()).toBe(2);
3070
+ });
3071
+
3072
+ test('TRACE-2 a project with more requirements than a list holds still draws its trail', async ($, on) => {
3073
+ const w = project();
3074
+ w.requirements = Array.from({ length: 134 }, (_, i) => ({
3075
+ id: `AILA-${i + 1}`,
3076
+ title: `Requirement ${i + 1}`,
3077
+ file: 'docs/spec/03-aila.md',
3078
+ removed: false,
3079
+ state: 'todo',
3080
+ stories: ['ST-4'],
3081
+ tests: [],
3082
+ }));
3083
+ await start($, on, w);
3084
+ await lanes($);
3085
+ const ui = await pane($, PANE);
3086
+ await press($, 'tab-map', PANE);
3087
+ expect((await ui.find({ key: 'map-select' }))?.props.options.length).toBe(64);
3088
+ await ui.unmount();
3089
+ });
3090
+
3091
+ test('MAP-5 with no story at work, the code map shows the whole project, by folder', async ($, on) => {
3092
+ const w = project();
3093
+ w.stories = w.stories.map((s) => ({ ...s, state: 'ready' }));
3094
+ w.map = {
3095
+ story: { id: 'project', title: 'The whole project', lane: null, state: null },
3096
+ layers: ['other'],
3097
+ files: [
3098
+ { path: 'apps/web/src', layer: 'other', lane: 'web', imports: ['packages/core'], files: 12 },
3099
+ { path: 'packages/core', layer: 'other', lane: 'core', imports: [], files: 4 },
3100
+ ],
3101
+ };
3102
+ await start($, on, w);
3103
+ await lanes($);
3104
+ const ui = await pane($, PANE);
3105
+ await press($, 'tab-code', PANE);
3106
+ expect((await ui.find({ key: 'map-story-project' }))?.props.variant).toBe('primary');
3107
+ expect(await ui.find({ type: 'Text', text: /hasn't changed any code yet/ })).toBeUndefined();
3108
+ expect(JSON.stringify(await ui.find({ key: 'map-stories' }))).toMatch(/Whole project/);
3109
+ await ui.unmount();
3110
+ });
@@ -0,0 +1,243 @@
1
+ // Teams in the mod (docs/specs/16-teams.md, TEAM-7, TEAM-9), the pure parts: what of the team's state
2
+ // needs this person, as band lines, and the Team tab. It reads `code-kit team --json` and
3
+ // `code-kit team since --json`; the CLI and the hooks do the rest.
4
+ import { globsOverlap, laneOfBranch } from '../lib/teamfacts.mjs';
5
+
6
+ const day = (iso) => {
7
+ const d = new Date(iso);
8
+ return Number.isNaN(d.getTime())
9
+ ? 'a while'
10
+ : d.toLocaleDateString('en-AU', { weekday: 'long', timeZone: 'UTC' });
11
+ };
12
+
13
+ /** What the person owns or is building: their lanes' paths and their claims' leases. */
14
+ function yoursOf(team, lanes = {}) {
15
+ const owned = Object.entries(team.owners ?? {})
16
+ .filter(([, people]) => people.includes(team.me))
17
+ .flatMap(([lane]) => lanes[lane]?.paths ?? []);
18
+ const leased = team.claims.filter((c) => c.by === team.me).flatMap((c) => c.lease);
19
+ return [...owned, ...leased];
20
+ }
21
+
22
+ const overlapping = (globs, others) => [
23
+ ...new Set(globs.filter((g) => others.some((o) => globsOverlap(g, o)))),
24
+ ];
25
+
26
+ /**
27
+ * TEAM-7, TEAM-9: band lines for what of the team needs this person, and only that: their stale
28
+ * claims (Keep or Release), a claim of theirs sharing files with someone else's, intents touching what
29
+ * they own or lease (every open intent, for the tech lead, with Agree and Drop), and reviews in lanes
30
+ * they own. `team` is `code-kit team --json`; `lanes` the config's lanes, for their paths.
31
+ */
32
+ export function teamLines(team, lanes = {}) {
33
+ if (!team) return [];
34
+ const lines = [];
35
+ if (team.reachable === false)
36
+ lines.push({
37
+ kind: 'teamDown',
38
+ text: "team: the shared repository can't be reached, so claims aren't checked",
39
+ actions: [],
40
+ });
41
+ const me = team.me;
42
+ if (!me) return lines;
43
+ const mine = team.claims.filter((c) => c.by === me);
44
+ for (const c of mine.filter((x) => x.stale))
45
+ lines.push({
46
+ kind: 'teamStale',
47
+ text: `Still on ${c.story}? Nothing on it since ${day(c.active)}`,
48
+ claim: c,
49
+ story: { id: c.story },
50
+ actions: [
51
+ { id: 'keepClaim', label: 'Keep' },
52
+ { id: 'releaseClaim', label: 'Release' },
53
+ ],
54
+ });
55
+ for (const c of mine)
56
+ for (const o of team.claims.filter((x) => x.by !== me)) {
57
+ const shared = overlapping(c.lease, o.lease);
58
+ if (shared.length)
59
+ lines.push({
60
+ kind: 'teamCollision',
61
+ text: `${c.story} and ${o.by}'s ${o.story} both change ${shared.join(', ')}`,
62
+ claim: c,
63
+ other: o,
64
+ story: { id: c.story },
65
+ actions: [{ id: 'team', label: 'Team' }],
66
+ });
67
+ }
68
+ const yours = yoursOf(team, lanes);
69
+ const leading = me === team.techLead;
70
+ for (const i of team.intents.filter((x) => x.by !== me)) {
71
+ const touched = overlapping(i.touches, yours);
72
+ if (!leading && !touched.length) continue;
73
+ lines.push({
74
+ kind: 'teamIntent',
75
+ text: `${i.by} intends: ${i.about}${touched.length ? ' (touches yours)' : ''}`,
76
+ intent: i,
77
+ actions: leading
78
+ ? [
79
+ { id: 'agreeIntent', label: 'Agree' },
80
+ { id: 'dropIntent', label: 'Drop' },
81
+ { id: 'team', label: 'Team' },
82
+ ]
83
+ : [{ id: 'team', label: 'Team' }],
84
+ });
85
+ }
86
+ for (const p of team.pulls.filter((x) => x.by !== me && !x.draft)) {
87
+ const lane = laneOfBranch(p.branch);
88
+ if (lane && team.owners?.[lane]?.includes(me))
89
+ lines.push({
90
+ kind: 'teamReview',
91
+ text: `${p.by}'s #${p.number} ${p.title} is in your ${lane} lane and waits for your review`,
92
+ pull: p,
93
+ actions: [{ id: 'team', label: 'Team' }],
94
+ });
95
+ }
96
+ return lines;
97
+ }
98
+
99
+ /** The people on the team, the tech lead first, then everyone named by an owner, claim, intent or pull. */
100
+ export function peopleOf(team) {
101
+ const named = [
102
+ team.techLead,
103
+ ...Object.values(team.owners ?? {}).flat(),
104
+ ...team.claims.map((c) => c.by),
105
+ ...team.intents.map((i) => i.by),
106
+ ...team.pulls.map((p) => p.by),
107
+ team.me,
108
+ ].filter(Boolean);
109
+ return [...new Set(named)];
110
+ }
111
+
112
+ /**
113
+ * TEAM-6, TEAM-7: the Team tab: since you last looked, then each person with the lanes they own, their
114
+ * claims (stale ones marked, with Keep and Release for yours), their pull requests and their intents
115
+ * (Agree and Drop for the tech lead). `on.onNeed(id, line)` acts as the band's buttons do.
116
+ */
117
+ export function teamTab(team, since, { Box, Text, Button }, on) {
118
+ const text = (value, style = {}) => Text({ ...style, children: [value] });
119
+ if (!team) return [];
120
+ const rows = [];
121
+ if (team.reachable === false)
122
+ rows.push(
123
+ text(
124
+ "The shared repository can't be reached: claims and leases aren't checked until it can be.",
125
+ {
126
+ color: 'yellow',
127
+ },
128
+ ),
129
+ );
130
+ const sincePeople = Object.entries(since?.people ?? {});
131
+ rows.push(text('Since you last looked', { bold: true }));
132
+ rows.push(
133
+ Box({
134
+ key: 'team-since',
135
+ flexDirection: 'column',
136
+ children: sincePeople.length
137
+ ? sincePeople.map(([who, events]) =>
138
+ Box({
139
+ key: `since-${who}`,
140
+ children: [
141
+ text(
142
+ ` ${who}${who === team.me ? ' (you)' : ''}: ${events.map((e) => e.text).join('; ')}`,
143
+ {
144
+ wrap: 'wrap',
145
+ },
146
+ ),
147
+ ],
148
+ }),
149
+ )
150
+ : [text(' Nothing new.', { dimColor: true })],
151
+ }),
152
+ );
153
+ for (const who of peopleOf(team)) {
154
+ const lanes = Object.entries(team.owners ?? {})
155
+ .filter(([, people]) => people.includes(who))
156
+ .map(([lane]) => lane);
157
+ const claims = team.claims.filter((c) => c.by === who);
158
+ const pulls = team.pulls.filter((p) => p.by === who);
159
+ const intents = team.intents.filter((i) => i.by === who);
160
+ const role = [
161
+ who === team.techLead ? 'tech lead' : null,
162
+ who === team.me ? 'you' : null,
163
+ lanes.length ? `owns ${lanes.join(', ')}` : null,
164
+ ].filter(Boolean);
165
+ const items = [
166
+ ...claims.map((c) =>
167
+ Box({
168
+ key: `team-claim-${c.story}`,
169
+ flexDirection: 'row',
170
+ columnGap: 1,
171
+ children: [
172
+ text(
173
+ ` ${c.story}${c.stale ? ` · stale since ${day(c.active)}` : ''}${c.lease.length ? ` · ${c.lease.join(', ')}` : ''}`,
174
+ { wrap: 'truncate-end', ...(c.stale ? { color: 'yellow' } : {}) },
175
+ ),
176
+ ...(who === team.me && c.stale
177
+ ? [
178
+ Button({
179
+ key: `team-keep-${c.story}`,
180
+ label: 'Keep',
181
+ onPress: () => on.onNeed('keepClaim', { kind: 'teamStale', claim: c }),
182
+ }),
183
+ Button({
184
+ key: `team-release-${c.story}`,
185
+ label: 'Release',
186
+ onPress: () => on.onNeed('releaseClaim', { kind: 'teamStale', claim: c }),
187
+ }),
188
+ ]
189
+ : []),
190
+ ],
191
+ }),
192
+ ),
193
+ ...pulls.map((p) =>
194
+ Box({
195
+ key: `team-pull-${p.number}`,
196
+ children: [
197
+ text(` #${p.number} ${p.title} (${p.branch}${p.draft ? ', draft' : ''})`, {
198
+ wrap: 'truncate-end',
199
+ }),
200
+ ],
201
+ }),
202
+ ),
203
+ ...intents.map((i) =>
204
+ Box({
205
+ key: `team-intent-${i.id}`,
206
+ flexDirection: 'row',
207
+ columnGap: 1,
208
+ children: [
209
+ text(` intends: ${i.about}${i.touches.length ? ` · ${i.touches.join(', ')}` : ''}`, {
210
+ wrap: 'truncate-end',
211
+ color: 'cyan',
212
+ }),
213
+ ...(team.me === team.techLead && i.by !== team.me
214
+ ? [
215
+ Button({
216
+ key: `team-agree-${i.id}`,
217
+ label: 'Agree',
218
+ onPress: () => on.onNeed('agreeIntent', { kind: 'teamIntent', intent: i }),
219
+ }),
220
+ Button({
221
+ key: `team-drop-${i.id}`,
222
+ label: 'Drop',
223
+ onPress: () => on.onNeed('dropIntent', { kind: 'teamIntent', intent: i }),
224
+ }),
225
+ ]
226
+ : []),
227
+ ],
228
+ }),
229
+ ),
230
+ ];
231
+ rows.push(
232
+ Box({
233
+ key: `team-person-${who}`,
234
+ flexDirection: 'column',
235
+ children: [
236
+ text(`${who}${role.length ? ` · ${role.join(' · ')}` : ''}`, { bold: true }),
237
+ ...(items.length ? items : [text(' nothing open', { dimColor: true })]),
238
+ ],
239
+ }),
240
+ );
241
+ }
242
+ return rows;
243
+ }
@@ -9,6 +9,7 @@ const LEAD = 'lead';
9
9
  /** A CLI run's stdout as JSON, or null when it printed none. */
10
10
  import { BAR, POSE_GLYPH, rasterCells, runs, sprite, spriteSvg, timelineSvg } from './art.mjs';
11
11
  import { missionLines, nextText } from './mission.mjs';
12
+ import { teamLines } from './teamview.mjs';
12
13
 
13
14
  export function parseJson(stdout) {
14
15
  try {
@@ -688,6 +689,7 @@ export function bandLines({
688
689
  live = {},
689
690
  nextStep = null,
690
691
  combined = null,
692
+ team = null,
691
693
  }) {
692
694
  const lines = [];
693
695
  // MISSION-1: the goal, now, next and the agents at work, above what needs the person.
@@ -798,18 +800,18 @@ export function bandLines({
798
800
  });
799
801
  // MQ-2: where the lead doesn't merge, the head of the queue waits for the person's Merge.
800
802
  const next = queue.find((e) => ['waiting', 'merging'].includes(e.state));
801
- const leadMerges =
802
- check?.delegatesMerge && (check?.harness?.autonomy ?? 'autonomous') === 'autonomous';
803
+ const autonomous = (check?.harness?.autonomy ?? 'autonomous') === 'autonomous';
804
+ // SETUP-2: with no rules on the base yet, the lead puts them there first and then merges.
805
+ const leadMerges = autonomous && (check?.delegatesMerge || check?.firstMerge);
803
806
  if (next && !leadMerges)
804
807
  lines.push({
805
808
  kind: 'queue',
806
- // The first merge brings code-kit's rules onto the base, so it's the person's even under autonomy.
807
- text: check?.firstMerge
808
- ? `${next.branch} is next to merge: the first merge is yours, then the lead merges`
809
- : `${next.branch} is next to merge`,
809
+ text: `${next.branch} is next to merge`,
810
810
  branch: next.branch,
811
811
  actions: [{ id: 'mergeQueue', label: 'Merge' }],
812
812
  });
813
+ // TEAM-7, TEAM-9: what of the team needs this person.
814
+ lines.push(...teamLines(team, check?.lanes ?? {}));
813
815
  // LOOP-7: the milestone is done; LOOP-6: the loop's own state, with Pause or Resume.
814
816
  if (loop?.state === 'done')
815
817
  lines.push({
@@ -931,7 +933,7 @@ export function band(lines, { Box, Text, Button }, onAction) {
931
933
  Text({ children: [''.padEnd(LABEL)] }),
932
934
  Button({
933
935
  key: 'band-decide',
934
- label: 'd Why, and what each choice does',
936
+ label: 'Why, and what each choice does',
935
937
  hotkey: 'd',
936
938
  plain: true,
937
939
  onPress: () => onAction('decide', first),
@@ -943,7 +945,7 @@ export function band(lines, { Box, Text, Button }, onAction) {
943
945
  }
944
946
 
945
947
  /** Band lines that inform rather than ask: they never need the person's answer. */
946
- const NOTE_KINDS = new Set(['usage', 'paused', 'finish', 'done']);
948
+ const NOTE_KINDS = new Set(['usage', 'paused', 'finish', 'done', 'teamDown']);
947
949
 
948
950
  /** The Approve… confirmation (ACT-1): what it grants, to whom, for how long, and the reason. */
949
951
  export function approvePane(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warren-dean/code-kit",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Keeps AI coding agents inside your project's rules: lanes, layer rules, protected paths and proof before finish, enforced by Claude Code hooks and in CI",
5
5
  "license": "MIT",
6
6
  "author": "Warren Langeveldt",
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: design
3
+ description: Design a design story's screens with the person before any of them is built, and record each approved design so its screen can be coded. Reads the screens the specs list, designs them in the project's design tool (Paper, Figma, Pencil) or as design files under design/, shows them, iterates on the person's feedback, and adds each approved screen to the design register the design gate reads. Use when code-kit next or the loop gives the design step, when a lane was refused for a screen whose design isn't approved, or runs /code-kit:design [story IDs].
4
+ ---
5
+
6
+ # Design before code
7
+
8
+ A screen is designed and approved before anyone builds it. The design gate refuses a lane writing a screen file until the design register lists it, and the plan's screen stories depend on their design story, so nothing is dispatched early. Designing is the lead's work with the person: you propose, they decide.
9
+
10
+ The CLI is `node "${CLAUDE_PLUGIN_ROOT}/bin/code-kit.mjs"`.
11
+
12
+ ## 1. What to design
13
+
14
+ For each story given (`**Lane:** design` in the plan):
15
+
16
+ - read the requirements it cites and the `## Screens` section of their specs: each screen, what it shows, what the person can do there, and its empty, loading and error states;
17
+ - read the architecture for the design tool and the folders screens live in, and the principles for the brand, theme and accessibility targets;
18
+ - look at the designs already approved (`design/register.json` and what it links to), so the new screens match them.
19
+
20
+ If the specs don't list the screens, or leave a state out, ask the person now rather than inventing it.
21
+
22
+ ## 2. Design
23
+
24
+ Use the project's design tool:
25
+
26
+ - **A design tool connected to this session** (Paper, Figma, Pencil, whichever the architecture names, or the one the person uses): design each screen there, one artboard per screen and state, with the project's tokens.
27
+ - **None:** write each screen as a static HTML and CSS file under `design/<area>/<screen>.html`, at the sizes the product targets (phone, desktop), opened in the browser to check it.
28
+
29
+ Use the spec's own content: the real labels, fields, messages and limits from its requirements, never lorem ipsum or placeholder screens. Every state the spec lists gets a design. Follow the principles for theme (light and dark, if both), type, spacing and accessibility.
30
+
31
+ ## 3. Show it and iterate
32
+
33
+ Show the person each screen (a screenshot, or the file to open) with one line on what it does and which requirements it serves. Ask what to change. Make the changes and show them again until they say a screen is approved. A screen they want rethought goes back to step 2; one that needs the spec changed goes to `/code-kit:spec-design` first.
34
+
35
+ ## 4. Record the approved designs
36
+
37
+ For each approved screen, add an entry to the register, naming the code file the lane will write (from the architecture's folders and the project's routing):
38
+
39
+ ```json
40
+ {
41
+ "screens": [
42
+ {
43
+ "file": "apps/web/app/bookings/page.tsx",
44
+ "design": "design/bookings/list.html",
45
+ "story": "ST-2",
46
+ "approved": "2026-10-11"
47
+ }
48
+ ]
49
+ }
50
+ ```
51
+
52
+ The register is protected by the `design` approval, which is the person's: writing it is refused until they approve, and in a session with the code-kit mod the write waits in the band for their Approve. Give the approval a reason in their words ("bookings list and detail, as shown"). Then commit the designs and the register on `design/st-<n>`, and review and merge it like the lead's own story. Once it's merged, `code-kit next` dispatches the screen stories that waited on it.
53
+
54
+ ## 5. Report
55
+
56
+ Say which screens were approved, which are still open and why, and what's dispatched next.