@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +29 -0
- package/README.md +63 -37
- package/bin/code-kit.mjs +278 -4
- package/hooks/guard-bash.mjs +64 -0
- package/hooks/guard-paths.mjs +19 -0
- package/hooks/lib/adapters/context-graph.mjs +5 -0
- package/hooks/lib/adapters/index.mjs +12 -0
- package/hooks/lib/config.mjs +15 -1
- package/hooks/lib/next.mjs +75 -3
- package/hooks/lib/plan.mjs +3 -1
- package/hooks/lib/storymap.mjs +62 -0
- package/hooks/lib/team.mjs +943 -0
- package/hooks/lib/teamfacts.mjs +187 -0
- package/hooks/mod/codemap.mjs +16 -1
- package/hooks/mod/decisions.mjs +24 -1
- package/hooks/mod/loop.mjs +20 -4
- package/hooks/mod/panes.mjs +99 -50
- package/hooks/mod/register.mjs +70 -1
- package/hooks/mod/register.test.ts +231 -2
- package/hooks/mod/teamview.mjs +243 -0
- package/hooks/mod/view.mjs +5 -1
- package/package.json +1 -1
- package/skills/design/SKILL.md +56 -0
- package/skills/dispatch/SKILL.md +9 -1
- package/skills/init/SKILL.md +19 -6
- package/skills/next/SKILL.md +3 -0
- package/skills/review/SKILL.md +1 -0
- package/skills/spec-design/SKILL.md +9 -3
- package/templates/plan.md +1 -1
|
@@ -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
|
+
}
|
package/hooks/mod/view.mjs
CHANGED
|
@@ -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.
|
|
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.
|
package/skills/dispatch/SKILL.md
CHANGED
|
@@ -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:
|