@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +29 -0
- package/README.md +65 -38
- package/bin/code-kit.mjs +355 -7
- package/hooks/guard-bash.mjs +69 -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 +88 -6
- package/hooks/lib/plan.mjs +3 -1
- package/hooks/lib/storymap.mjs +62 -0
- package/hooks/lib/team.mjs +851 -0
- package/hooks/lib/teamfacts.mjs +187 -0
- package/hooks/mod/codemap.mjs +16 -1
- package/hooks/mod/decisions.mjs +25 -2
- package/hooks/mod/loop.mjs +23 -3
- package/hooks/mod/panes.mjs +99 -50
- package/hooks/mod/register.mjs +70 -1
- package/hooks/mod/register.test.ts +251 -9
- package/hooks/mod/teamview.mjs +243 -0
- package/hooks/mod/view.mjs +10 -8
- 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';
|
|
@@ -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(
|
|
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
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
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
|
+
}
|
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.
|
|
@@ -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
|
|
802
|
-
|
|
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
|
-
|
|
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: '
|
|
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.
|
|
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.
|