@warren-dean/code-kit 0.8.1 → 0.9.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.
@@ -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';
@@ -2465,13 +2480,18 @@ test('TRACE-2 selecting a cell, by keys or the list, shows its spec, stories wit
2465
2480
  expect(JSON.stringify((await ui.find({ key: 'cell-BOOK-21' }))?.children)).toMatch(
2466
2481
  /"inverse":true/,
2467
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);
2468
2490
  await $.ui.select({ plugin: 'code-kit', key: 'map-select', value: 'BOOK-3', requestId: PANE });
2469
2491
  const detail = (await ui.find({ key: 'map-detail' }))?.text ?? '';
2470
2492
  expect(detail).toMatch(/docs\/spec\/01-booking.md · done and tested/);
2471
2493
  expect(detail).toMatch(/ST-4 Booking form · web · in progress · web\/st-4/);
2472
2494
  expect(detail).toMatch(/apps\/web\/b3.test.ts/);
2473
- await $.ui.select({ plugin: 'code-kit', key: 'map-select', value: 'BOOK-40', requestId: PANE });
2474
- expect((await ui.find({ key: 'map-detail' }))?.text).toMatch(/No story delivers it yet/);
2475
2495
  await ui.unmount();
2476
2496
  });
2477
2497
 
@@ -2879,3 +2899,212 @@ test('the lead reads orchestrating while its turn runs, and idle once it ends; a
2879
2899
  expect((await ui.find({ key: 'lane-lead' }))?.text).toMatch(/lead\s*idle/);
2880
2900
  await ui.unmount();
2881
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.
@@ -808,6 +810,8 @@ export function bandLines({
808
810
  branch: next.branch,
809
811
  actions: [{ id: 'mergeQueue', label: 'Merge' }],
810
812
  });
813
+ // TEAM-7, TEAM-9: what of the team needs this person.
814
+ lines.push(...teamLines(team, check?.lanes ?? {}));
811
815
  // LOOP-7: the milestone is done; LOOP-6: the loop's own state, with Pause or Resume.
812
816
  if (loop?.state === 'done')
813
817
  lines.push({
@@ -941,7 +945,7 @@ export function band(lines, { Box, Text, Button }, onAction) {
941
945
  }
942
946
 
943
947
  /** Band lines that inform rather than ask: they never need the person's answer. */
944
- const NOTE_KINDS = new Set(['usage', 'paused', 'finish', 'done']);
948
+ const NOTE_KINDS = new Set(['usage', 'paused', 'finish', 'done', 'teamDown']);
945
949
 
946
950
  /** The Approve… confirmation (ACT-1): what it grants, to whom, for how long, and the reason. */
947
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.1",
3
+ "version": "0.9.1",
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.
@@ -17,7 +17,7 @@ The CLI is `node "${CLAUDE_PLUGIN_ROOT}/bin/code-kit.mjs"`, run from the project
17
17
  node "${CLAUDE_PLUGIN_ROOT}/bin/code-kit.mjs" status
18
18
  ```
19
19
 
20
- A story is **ready** when its dependencies are done and nobody has started it. `status` also lists gaps between the specs and the plan; a story with a gap (an unknown lane, a requirement no spec defines) isn't dispatched until the lead fixes the plan. Stories in the `lead` lane are never dispatched: the lead builds them itself.
20
+ A story is **ready** when its dependencies are done and nobody has started it. `status` also lists gaps between the specs and the plan; a story with a gap (an unknown lane, a requirement no spec defines) isn't dispatched until the lead fixes the plan. Stories in the `lead` lane are never dispatched: the lead builds them itself. Nor are design stories (`**Lane:** design`): the lead designs them with the person (`/code-kit:design`), and the screen stories that depend on them wait until their designs are approved and merged.
21
21
 
22
22
  If the project has no `docs.plan`, or the plan has no stories in the `### ST-n` form (`${CLAUDE_PLUGIN_ROOT}/templates/plan.md`), stop and suggest the spec-design skill.
23
23
 
@@ -40,6 +40,14 @@ Commit everything the lanes need first. Each lane branches from the lead's branc
40
40
 
41
41
  ## 3. Brief and start each lane
42
42
 
43
+ **In a team** (the config has `team`): claim each story before you brief its agent, leasing what it will change, so nobody else's agents build on the same files meanwhile:
44
+
45
+ ```bash
46
+ node "${CLAUDE_PLUGIN_ROOT}/bin/code-kit.mjs" team claim ST-<n> --lease "<glob>,<glob>"
47
+ ```
48
+
49
+ The lease is the paths the story will create or change: its lane's folders for this feature, and any contract it changes. A claim someone else holds is refused with who holds it; pick another story or ask them. Stories in a lane another person owns are theirs to dispatch. Making the story's branch also claims it, and each commit on it adds what it changed to the lease; merging it ends the claim.
50
+
43
51
  Start each story with the Agent tool: `subagent_type` is the lane's agent, `isolation: "worktree"`, and run them in the background. Start every story in the batch in the same message so they run in parallel.
44
52
 
45
53
  The brief is the agent's whole context. Write it so the agent needs nothing else: