@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
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Teams (docs/specs/16-teams.md), the pure parts: names, story branches, staleness and what an intent
|
|
2
|
+
// touches. Shared by the hooks and CLI (team.mjs) and the mod, which runs without Node.
|
|
3
|
+
|
|
4
|
+
/** A GitHub handle: letters, digits and single hyphens, at most 39 characters. */
|
|
5
|
+
export const isHandle = (h) =>
|
|
6
|
+
typeof h === 'string' && /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/.test(h);
|
|
7
|
+
|
|
8
|
+
/** A story id a ref can carry: ST-12, AUTH-3. */
|
|
9
|
+
export const isStoryId = (s) => typeof s === 'string' && /^[A-Za-z][A-Za-z0-9]*-\d+$/.test(s);
|
|
10
|
+
|
|
11
|
+
/** The paths an intent touches, from its issue's body ("Touches: a/**, b"). */
|
|
12
|
+
export const touchesOf = (body) =>
|
|
13
|
+
(String(body ?? '').match(/^Touches:\s*(.+)$/m)?.[1] ?? '')
|
|
14
|
+
.split(',')
|
|
15
|
+
.map((t) => t.trim().replace(/^`|`$/g, ''))
|
|
16
|
+
.filter(Boolean);
|
|
17
|
+
|
|
18
|
+
/** Whole working days (Monday to Friday) from `from` to `to`, both in ms. */
|
|
19
|
+
export function workingDaysBetween(from, to) {
|
|
20
|
+
let days = 0;
|
|
21
|
+
const d = new Date(from);
|
|
22
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
23
|
+
const end = new Date(to);
|
|
24
|
+
end.setUTCHours(0, 0, 0, 0);
|
|
25
|
+
while (d < end) {
|
|
26
|
+
d.setUTCDate(d.getUTCDate() + 1);
|
|
27
|
+
if (d.getUTCDay() !== 0 && d.getUTCDay() !== 6) days += 1;
|
|
28
|
+
}
|
|
29
|
+
return days;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** TEAM-9: a claim with when it was last worked on (claimed, or its branch pushed) and whether it's stale. */
|
|
33
|
+
export function withStaleness(claim, lastPush, staleDays, now = Date.now()) {
|
|
34
|
+
const active = Math.max(Date.parse(claim.at) || 0, lastPush ?? 0);
|
|
35
|
+
return {
|
|
36
|
+
...claim,
|
|
37
|
+
active: new Date(active).toISOString(),
|
|
38
|
+
stale: workingDaysBetween(active, now) >= staleDays,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The story a branch is for: web/st-4 → ST-4. Null for any other branch. */
|
|
43
|
+
export const storyOfBranch = (branch) => {
|
|
44
|
+
const m = String(branch ?? '').match(/^[a-z][a-z0-9-]*\/([a-z][a-z0-9]*-\d+)$/i);
|
|
45
|
+
return m ? m[1].toUpperCase() : null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** The lane a story branch is in: web/st-4 → web. */
|
|
49
|
+
export const laneOfBranch = (branch) => (storyOfBranch(branch) ? branch.split('/')[0] : null);
|
|
50
|
+
|
|
51
|
+
/** Whether two path globs can name the same file: one's fixed start begins the other's. */
|
|
52
|
+
export function globsOverlap(a, b) {
|
|
53
|
+
const fixed = (g) => g.split(/[*?{]/)[0];
|
|
54
|
+
const [x, y] = [fixed(a), fixed(b)];
|
|
55
|
+
return x.startsWith(y) || y.startsWith(x);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** An intent from its discussion: { id, by, about, touches, at, url, state }. */
|
|
59
|
+
export const intentOf = (d) => ({
|
|
60
|
+
id: d.number,
|
|
61
|
+
by: d.author?.login ?? null,
|
|
62
|
+
about: d.title,
|
|
63
|
+
touches: touchesOf(d.body),
|
|
64
|
+
at: d.createdAt,
|
|
65
|
+
url: d.url,
|
|
66
|
+
state: !d.closed
|
|
67
|
+
? 'open'
|
|
68
|
+
: d.stateReason === 'DUPLICATE'
|
|
69
|
+
? 'duplicate'
|
|
70
|
+
: d.stateReason === 'OUTDATED'
|
|
71
|
+
? 'dropped'
|
|
72
|
+
: 'agreed',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* TEAM-6: intents raised and settled since `at` (ms), from their discussions: [{ at, by, kind, text }].
|
|
77
|
+
* A settled one is the tech lead's act; its discussion doesn't say who closed it, so it reads as theirs.
|
|
78
|
+
*/
|
|
79
|
+
export function discussionActivity(nodes, at, techLead = null) {
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const d of nodes) {
|
|
82
|
+
const name = `#${d.number} ${d.title}`;
|
|
83
|
+
const raised = Date.parse(d.createdAt);
|
|
84
|
+
if (raised > at)
|
|
85
|
+
out.push({
|
|
86
|
+
at: raised,
|
|
87
|
+
by: d.author?.login ?? 'someone',
|
|
88
|
+
kind: 'intended',
|
|
89
|
+
text: `raised intent ${name}`,
|
|
90
|
+
});
|
|
91
|
+
const closed = Date.parse(d.closedAt);
|
|
92
|
+
if (d.closed && closed > at) {
|
|
93
|
+
const state = intentOf(d).state;
|
|
94
|
+
out.push({
|
|
95
|
+
at: closed,
|
|
96
|
+
by: techLead ?? 'the tech lead',
|
|
97
|
+
kind: state === 'agreed' ? 'agreed' : 'dropped',
|
|
98
|
+
text: `${state === 'duplicate' ? 'marked as a duplicate' : state} intent ${name}`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A title as a file name: "Split the report format" → "split-the-report-format". */
|
|
106
|
+
export const slugOf = (title) =>
|
|
107
|
+
String(title)
|
|
108
|
+
.toLowerCase()
|
|
109
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
110
|
+
.replace(/^-|-$/g, '')
|
|
111
|
+
.slice(0, 60) || 'decision';
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* TEAM-10: an agreed intent as a decision record, from its discussion's facts only: { title, body, url,
|
|
115
|
+
* author, createdAt, closedAt, comments: [{ author, body }] }. The tech lead's words are their comment
|
|
116
|
+
* agreeing it.
|
|
117
|
+
*/
|
|
118
|
+
export function decisionRecord(d, techLead) {
|
|
119
|
+
const date = String(d.closedAt ?? d.createdAt).slice(0, 10);
|
|
120
|
+
const touches = touchesOf(d.body);
|
|
121
|
+
const why = String(d.body ?? '')
|
|
122
|
+
.replace(/^Touches:.*$/m, '')
|
|
123
|
+
.replace(/Raised with code-kit by @\S+ before any code[^\n]*/, '')
|
|
124
|
+
.trim();
|
|
125
|
+
const agreed = (d.comments ?? [])
|
|
126
|
+
.filter((c) => c.author?.login === techLead && /^Agreed by @/.test(c.body))
|
|
127
|
+
.at(-1)
|
|
128
|
+
?.body.replace(/^Agreed by @\S+, the tech lead(: |\.)?/, '')
|
|
129
|
+
.trim();
|
|
130
|
+
return [
|
|
131
|
+
`# ${d.title}`,
|
|
132
|
+
'',
|
|
133
|
+
'**Status:** agreed',
|
|
134
|
+
`**Date:** ${date}`,
|
|
135
|
+
`**Raised by:** @${d.author?.login ?? 'someone'} · **Agreed by:** @${techLead}`,
|
|
136
|
+
`**Discussion:** ${d.url}`,
|
|
137
|
+
'',
|
|
138
|
+
'## What changes, and why',
|
|
139
|
+
'',
|
|
140
|
+
why || '(The intent said no more than its title.)',
|
|
141
|
+
...(touches.length ? ['', '## What it touches', '', ...touches.map((t) => `- \`${t}\``)] : []),
|
|
142
|
+
...(agreed ? ['', '## Agreed', '', agreed] : []),
|
|
143
|
+
'',
|
|
144
|
+
].join('\n');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A config glob as CODEOWNERS patterns, anchored at the root; `{a,b}` becomes one pattern each. */
|
|
148
|
+
export function ownersPatterns(glob) {
|
|
149
|
+
const alt = glob.match(/\{([^{}]*)\}/);
|
|
150
|
+
if (alt) return alt[1].split(',').flatMap((x) => ownersPatterns(glob.replace(alt[0], x)));
|
|
151
|
+
return [glob.startsWith('**') || glob.startsWith('/') ? glob : `/${glob}`];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* TEAM-11: CODEOWNERS from the config: the tech lead for the tech lead's paths, then each owned lane's
|
|
156
|
+
* owners for its paths (later lines win in CODEOWNERS, so lanes come after). `leadPaths` are the
|
|
157
|
+
* project's own lead paths; `lanes` the config's lanes with their owners.
|
|
158
|
+
*/
|
|
159
|
+
export function codeowners({ techLead, leadPaths, lanes }) {
|
|
160
|
+
const line = (glob, people) =>
|
|
161
|
+
ownersPatterns(glob).map((p) => `${p} ${people.map((h) => `@${h}`).join(' ')}`);
|
|
162
|
+
const out = [
|
|
163
|
+
'# Generated by `code-kit team codeowners --write` from .claude/code-kit.json.',
|
|
164
|
+
'# Change the tech lead or the lanes’ owners there, then generate it again.',
|
|
165
|
+
'',
|
|
166
|
+
'# The tech lead: the plan, specs and contracts.',
|
|
167
|
+
...leadPaths.filter((p) => p !== '*' && p !== '.claude/**').flatMap((p) => line(p, [techLead])),
|
|
168
|
+
];
|
|
169
|
+
for (const [name, lane] of Object.entries(lanes)) {
|
|
170
|
+
if (!lane.owners?.length) continue;
|
|
171
|
+
out.push('', `# The ${name} lane.`, ...lane.paths.flatMap((p) => line(p, lane.owners)));
|
|
172
|
+
}
|
|
173
|
+
return `${out.join('\n')}\n`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** TEAM-12: the board's fields for a story, all text: Lane, Claimed by, Stale and State. */
|
|
177
|
+
export const BOARD_FIELDS = ['Lane', 'Claimed by', 'Stale', 'State'];
|
|
178
|
+
export const boardValues = (story, claim) => ({
|
|
179
|
+
Lane: story.lane ?? '',
|
|
180
|
+
'Claimed by': claim?.by ?? '',
|
|
181
|
+
Stale: claim?.stale ? 'yes' : '',
|
|
182
|
+
State: story.state ?? '',
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
/** The fields whose value changed since the last sync: [[field, value]]. */
|
|
186
|
+
export const boardChanges = (before = {}, now) =>
|
|
187
|
+
BOARD_FIELDS.filter((f) => (before[f] ?? '') !== now[f]).map((f) => [f, now[f]]);
|
package/hooks/mod/codemap.mjs
CHANGED
|
@@ -309,13 +309,28 @@ export function neighboursOf(map, path) {
|
|
|
309
309
|
};
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
+
/** MAP-5: the map of the whole project, by folder. */
|
|
313
|
+
export const PROJECT = 'project';
|
|
314
|
+
|
|
315
|
+
/** Each glowing file shown on the folder holding it, for the project map's folder nodes. */
|
|
316
|
+
export function glowByFolder(glow, folders) {
|
|
317
|
+
const out = {};
|
|
318
|
+
const byDepth = [...folders].sort((a, b) => b.length - a.length);
|
|
319
|
+
for (const [path, g] of Object.entries(glow)) {
|
|
320
|
+
const folder = byDepth.find((f) => f === '.' || path === f || path.startsWith(`${f}/`));
|
|
321
|
+
if (folder) out[folder] = g;
|
|
322
|
+
}
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
|
|
312
326
|
/** The story the code map opens on: the one asked for, else the selected lane's, else one at work. */
|
|
313
327
|
export function mapStoryOf(state, selectedLane, asked = null) {
|
|
314
328
|
if (asked) return asked;
|
|
315
329
|
const lane = state?.lanes?.find((l) => l.name === selectedLane);
|
|
316
330
|
if (lane?.story) return lane.story.id;
|
|
317
331
|
const busy = state?.lanes?.find((l) => l.story && ['building', 'in review'].includes(l.state));
|
|
318
|
-
|
|
332
|
+
// MAP-5: with no story at work, the whole project, not a story that has no code yet.
|
|
333
|
+
return busy?.story.id ?? PROJECT;
|
|
319
334
|
}
|
|
320
335
|
|
|
321
336
|
/** MAP-2, MAP-3: the map's legend: what the colours and words mean, a card being the file's why. */
|
package/hooks/mod/decisions.mjs
CHANGED
|
@@ -12,6 +12,10 @@ export const DECISION_KINDS = new Set([
|
|
|
12
12
|
'queue',
|
|
13
13
|
'review',
|
|
14
14
|
'notice',
|
|
15
|
+
'teamStale',
|
|
16
|
+
'teamCollision',
|
|
17
|
+
'teamIntent',
|
|
18
|
+
'teamReview',
|
|
15
19
|
]);
|
|
16
20
|
|
|
17
21
|
/** What each answer does, in the person's words. */
|
|
@@ -29,6 +33,12 @@ const EFFECT = {
|
|
|
29
33
|
review: 'The lead reviews it against its requirements now.',
|
|
30
34
|
merge: "It's verified and merged now, by you.",
|
|
31
35
|
dismiss: 'Clears this message.',
|
|
36
|
+
keepClaim: 'Your claim stays, and its clock starts again.',
|
|
37
|
+
releaseClaim: 'Anyone may claim the story now, and your lease on its files ends.',
|
|
38
|
+
agreeIntent:
|
|
39
|
+
'The issue closes as agreed, with your words; then put it in the specs or decisions so every agent builds on it.',
|
|
40
|
+
dropIntent: 'The issue closes as dropped, and the team carries on as agreed before.',
|
|
41
|
+
team: 'Opens the Team tab: who is building what, and what changed since you last looked.',
|
|
32
42
|
};
|
|
33
43
|
|
|
34
44
|
/** Why a line needs the person, when the line itself doesn't say. */
|
|
@@ -50,9 +60,18 @@ function whyOf(line, check) {
|
|
|
50
60
|
if (line.kind === 'proposal') return 'You asked to approve each step before the lead takes it.';
|
|
51
61
|
if (line.kind === 'queue')
|
|
52
62
|
return check?.firstMerge
|
|
53
|
-
? 'main has no rules committed yet
|
|
63
|
+
? 'The loop is off, so merging is yours. main has no rules committed yet: code-kit queue rules puts them there.'
|
|
54
64
|
: "Your rules don't let the lead merge into main, so merges are yours.";
|
|
55
65
|
if (line.kind === 'review') return 'The loop is off, so reviewing finished work is yours.';
|
|
66
|
+
if (line.kind === 'teamStale')
|
|
67
|
+
return "It's your claim: nobody else can build it while you hold it, and the tech lead may hand it on.";
|
|
68
|
+
if (line.kind === 'teamCollision')
|
|
69
|
+
return `Your agents and ${line.other.by}'s are changing the same files; whoever merges second resolves the conflict. Agree who goes first.`;
|
|
70
|
+
if (line.kind === 'teamIntent' && line.intent && /touches yours/.test(line.text))
|
|
71
|
+
return 'It changes what you own or are building: read it before your agents build further on the old way.';
|
|
72
|
+
if (line.kind === 'teamIntent')
|
|
73
|
+
return "You're the tech lead: a change of direction is agreed or dropped by you, and until then everyone builds on what was agreed.";
|
|
74
|
+
if (line.kind === 'teamReview') return "It's in a lane you own, so its review is yours.";
|
|
56
75
|
return null;
|
|
57
76
|
}
|
|
58
77
|
|
|
@@ -130,7 +149,11 @@ export function decisionCards(lines, context = {}) {
|
|
|
130
149
|
doing:
|
|
131
150
|
line.held?.command ??
|
|
132
151
|
line.request?.what ??
|
|
133
|
-
(line.proposal?.label ? `Next step: ${line.proposal.label}` : null)
|
|
152
|
+
(line.proposal?.label ? `Next step: ${line.proposal.label}` : null) ??
|
|
153
|
+
(line.intent
|
|
154
|
+
? `Intent #${line.intent.id}: ${line.intent.about}${line.intent.touches.length ? ` (touches ${line.intent.touches.join(', ')})` : ''}`
|
|
155
|
+
: null) ??
|
|
156
|
+
(line.pull ? `Pull request #${line.pull.number} on ${line.pull.branch}` : null),
|
|
134
157
|
settled,
|
|
135
158
|
why: whyOf(line, check),
|
|
136
159
|
options: line.actions.map((a) => ({ ...a, effect: EFFECT[a.id] ?? null })),
|
package/hooks/mod/loop.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// existing skills, and the hooks judge every call as before.
|
|
4
4
|
|
|
5
5
|
/** The steps the loop hands the lead; the rest (spec-design, init, check) wait for the person. */
|
|
6
|
-
const LEAD_STEPS = new Set(['dispatch', 'review', 'lead', 'merge']);
|
|
6
|
+
const LEAD_STEPS = new Set(['dispatch', 'review', 'lead', 'merge', 'rules', 'design', 'setup']);
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* The prompt for a step from `code-kit next --json` (LOOP-1), or null when it isn't the lead's to
|
|
@@ -19,6 +19,19 @@ export function leadPrompt(step, { cli = 'code-kit' } = {}) {
|
|
|
19
19
|
return ids.length
|
|
20
20
|
? `Merge ${ids[0]}, next in the merge queue: run node "${cli}" queue merge --delegated. It verifies the branch against the base as it is now, and sends it back to its lane if it conflicts or fails.`
|
|
21
21
|
: null;
|
|
22
|
+
// SETUP-2: the base has no rules yet; the lead puts the approved ones there, then merges as usual.
|
|
23
|
+
if (step.step === 'rules')
|
|
24
|
+
return `Put code-kit's approved rules onto ${ids[0] ?? 'the base'}: run node "${cli}" queue rules. It commits the rules as committed on this branch, without switching branches; after it, merge the queue as usual.`;
|
|
25
|
+
// ONB-3: a companion set up before any coding.
|
|
26
|
+
if (step.step === 'setup')
|
|
27
|
+
return ids.length
|
|
28
|
+
? `Before any code is written, set up ${ids[0].split(':')[0]}: run /${ids[0]}. ${step.why ?? ''}`.trim()
|
|
29
|
+
: null;
|
|
30
|
+
// ONB-2: the screens designed, and approved by the person, before they're built.
|
|
31
|
+
if (step.step === 'design')
|
|
32
|
+
return ids.length
|
|
33
|
+
? `Design ${ids.join(', ')} before any of their screens are built: run /code-kit:design ${ids.join(' ')}.`
|
|
34
|
+
: null;
|
|
22
35
|
if (step.step === 'dispatch')
|
|
23
36
|
return ids.length
|
|
24
37
|
? `Dispatch ${ids.join(', ')}: run /code-kit:dispatch ${ids.join(' ')}.`
|
|
@@ -40,8 +53,15 @@ export const stepKey = (step) => `${step.step}:${step.args ?? ''}`;
|
|
|
40
53
|
export function stepLabel(step) {
|
|
41
54
|
const ids = (step.args ?? '').split(/\s+/).filter(Boolean).join(', ');
|
|
42
55
|
const verb =
|
|
43
|
-
{
|
|
44
|
-
|
|
56
|
+
{
|
|
57
|
+
dispatch: 'Dispatch',
|
|
58
|
+
review: 'Review',
|
|
59
|
+
lead: 'Build',
|
|
60
|
+
merge: 'Merge',
|
|
61
|
+
rules: 'Rules onto',
|
|
62
|
+
design: 'Design',
|
|
63
|
+
setup: 'Set up',
|
|
64
|
+
}[step.step] ?? step.step;
|
|
45
65
|
return ids ? `${verb} ${ids}` : verb;
|
|
46
66
|
}
|
|
47
67
|
|
package/hooks/mod/panes.mjs
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// Panes v2 (docs/specs/11-panes.md): the Lanes pane's frame. A counts header, tabs, what needs the
|
|
2
2
|
// person, the tab's body and the keys, as pure functions of what the mod already holds. The Lanes
|
|
3
3
|
// tab's body is view.mjs's lanesPane; the Queue and Usage tabs reuse its sections.
|
|
4
|
+
import { teamTab } from './teamview.mjs';
|
|
4
5
|
import { trailRows as trailRowsDrawn, trailSvg } from './trail.mjs';
|
|
5
6
|
import {
|
|
6
7
|
MAP_LEGEND,
|
|
8
|
+
PROJECT,
|
|
7
9
|
cardOf,
|
|
10
|
+
glowByFolder,
|
|
8
11
|
mapRows as codeRows,
|
|
9
12
|
mapStoryOf,
|
|
10
13
|
mapSvg,
|
|
@@ -29,6 +32,7 @@ export const TABS = [
|
|
|
29
32
|
{ id: 'map', label: 'Trail', hotkey: '5' },
|
|
30
33
|
{ id: 'queue', label: 'Queue', hotkey: '6' },
|
|
31
34
|
{ id: 'usage', label: 'Usage', hotkey: '7' },
|
|
35
|
+
{ id: 'team', label: 'Team', hotkey: '8' },
|
|
32
36
|
];
|
|
33
37
|
|
|
34
38
|
/** The pane's own state, kept by the mod between draws. */
|
|
@@ -242,7 +246,8 @@ export function lanesFrame(view, els, on) {
|
|
|
242
246
|
key: 'tabs',
|
|
243
247
|
flexDirection: 'row',
|
|
244
248
|
columnGap: 1,
|
|
245
|
-
|
|
249
|
+
// The Team tab is there only for a team (TEAM-7).
|
|
250
|
+
children: TABS.filter((t) => t.id !== 'team' || view.team).map((t) =>
|
|
246
251
|
Button({
|
|
247
252
|
key: `tab-${t.id}`,
|
|
248
253
|
label: t.label,
|
|
@@ -267,48 +272,52 @@ export function lanesFrame(view, els, on) {
|
|
|
267
272
|
|
|
268
273
|
const selected = state.lanes.find((l) => l.name === ui.selected) ?? null;
|
|
269
274
|
const body =
|
|
270
|
-
ui.tab === '
|
|
271
|
-
?
|
|
272
|
-
: ui.tab === '
|
|
273
|
-
?
|
|
274
|
-
: ui.tab === '
|
|
275
|
-
?
|
|
276
|
-
: ui.tab === '
|
|
277
|
-
?
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
: ui.tab === '
|
|
284
|
-
?
|
|
285
|
-
:
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
275
|
+
ui.tab === 'team'
|
|
276
|
+
? teamTab(view.team ?? null, view.teamSince ?? null, els, on)
|
|
277
|
+
: ui.tab === 'decisions'
|
|
278
|
+
? decisionsTab(view.cards ?? [], ui, els, on)
|
|
279
|
+
: ui.tab === 'journal'
|
|
280
|
+
? journalTab(view.journal ?? [], els, view.now)
|
|
281
|
+
: ui.tab === 'code'
|
|
282
|
+
? codeTab(view, els, on)
|
|
283
|
+
: ui.tab === 'map'
|
|
284
|
+
? mapTab(view.status, ui.cell, els, on, view.trail, {
|
|
285
|
+
surface: view.surface,
|
|
286
|
+
columns: view.columns,
|
|
287
|
+
})
|
|
288
|
+
: ui.tab === 'queue'
|
|
289
|
+
? queueSection(loop, text, Box)
|
|
290
|
+
: ui.tab === 'usage'
|
|
291
|
+
? usageSection(view.usage, plan, text, Box)
|
|
292
|
+
: [
|
|
293
|
+
lanesPane(state, els, view.usage, plan, loop, {
|
|
294
|
+
filter: ui.filter,
|
|
295
|
+
search: ui.search,
|
|
296
|
+
selected: ui.selected,
|
|
297
|
+
live: view.live ?? {},
|
|
298
|
+
art: view.art ?? null,
|
|
299
|
+
detail: selected
|
|
300
|
+
? {
|
|
301
|
+
agent: selected.agent ?? null,
|
|
302
|
+
owns: view.paths?.[selected.name] ?? [],
|
|
303
|
+
stories: laneStories(selected, state),
|
|
304
|
+
}
|
|
305
|
+
: null,
|
|
306
|
+
combined: view.combined ?? null,
|
|
307
|
+
}),
|
|
308
|
+
];
|
|
302
309
|
const empty =
|
|
303
|
-
ui.tab === '
|
|
304
|
-
? '
|
|
305
|
-
: ui.tab === '
|
|
306
|
-
? 'Nothing
|
|
307
|
-
: ui.tab === '
|
|
308
|
-
? '
|
|
309
|
-
: ui.tab === '
|
|
310
|
-
? '
|
|
311
|
-
:
|
|
310
|
+
ui.tab === 'team'
|
|
311
|
+
? 'This project has no team in its config.'
|
|
312
|
+
: ui.tab === 'decisions'
|
|
313
|
+
? 'Nothing needs you. The lead is handling the build.'
|
|
314
|
+
: ui.tab === 'journal'
|
|
315
|
+
? 'Nothing recorded yet: the journal starts with the brief.'
|
|
316
|
+
: ui.tab === 'queue'
|
|
317
|
+
? 'The merge queue is empty.'
|
|
318
|
+
: ui.tab === 'usage'
|
|
319
|
+
? 'No usage measured yet this session.'
|
|
320
|
+
: null;
|
|
312
321
|
|
|
313
322
|
// VIEW-6: j/k move between lanes; the selected lane's acts; f filters.
|
|
314
323
|
const opening = selected ? storyToOpen(selected, state) : null;
|
|
@@ -841,6 +850,21 @@ export function moveCell(requirements, id, step, { rows = false } = {}) {
|
|
|
841
850
|
return specs[(row + step + specs.length) % specs.length].requirements[0].id;
|
|
842
851
|
}
|
|
843
852
|
|
|
853
|
+
/** The engine's limit on a Select's options. */
|
|
854
|
+
export const SELECT_MAX = 64;
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* TRACE-2: the requirements the picker offers: those in the selected one's spec, at most SELECT_MAX,
|
|
858
|
+
* a window around the selected one when the spec has more.
|
|
859
|
+
*/
|
|
860
|
+
export function pickerOf(requirements, selected) {
|
|
861
|
+
const same = requirements.filter((r) => r.file === selected.file);
|
|
862
|
+
if (same.length <= SELECT_MAX) return same;
|
|
863
|
+
const at = same.findIndex((r) => r.id === selected.id);
|
|
864
|
+
const start = Math.max(0, Math.min(at - SELECT_MAX / 2, same.length - SELECT_MAX));
|
|
865
|
+
return same.slice(start, start + SELECT_MAX);
|
|
866
|
+
}
|
|
867
|
+
|
|
844
868
|
/**
|
|
845
869
|
* The Map tab (TRACE-1 to TRACE-4): a row per spec, a cell per requirement, a legend, and the
|
|
846
870
|
* selected requirement's stories and tests. `status` is `code-kit status --json`'s.
|
|
@@ -946,7 +970,8 @@ export function mapTab(
|
|
|
946
970
|
key: 'map-select',
|
|
947
971
|
label: 'Requirement',
|
|
948
972
|
value: selected.id,
|
|
949
|
-
options:
|
|
973
|
+
// A Select holds at most 64 options: the selected requirement's own spec, around it.
|
|
974
|
+
options: pickerOf(requirements, selected).map((r) => ({
|
|
950
975
|
value: r.id,
|
|
951
976
|
label: `${CELL[cellOf(r)].glyph} ${r.id} ${r.title}`,
|
|
952
977
|
})),
|
|
@@ -1080,11 +1105,16 @@ const RUN_STYLE = {
|
|
|
1080
1105
|
*/
|
|
1081
1106
|
export function codeTab(view, { Box, Text, Button, Svg }, on) {
|
|
1082
1107
|
const text = (value, style = {}) => Text({ ...style, children: [value] });
|
|
1083
|
-
const { state, ui = DEFAULT_UI, codeMap: map = null,
|
|
1108
|
+
const { state, ui = DEFAULT_UI, codeMap: map = null, art = null } = view;
|
|
1109
|
+
let glow = view.glow ?? {};
|
|
1084
1110
|
const storyId = mapStoryOf(state, ui.selected, ui.mapStory);
|
|
1085
|
-
const
|
|
1086
|
-
|
|
1087
|
-
|
|
1111
|
+
const project = { id: PROJECT, title: 'Whole project' };
|
|
1112
|
+
const choices = [
|
|
1113
|
+
project,
|
|
1114
|
+
...(state?.stories ?? []).filter(
|
|
1115
|
+
(s) => s.id === storyId || ['in progress', 'review', 'sent back'].includes(s.state),
|
|
1116
|
+
),
|
|
1117
|
+
];
|
|
1088
1118
|
const picker = Box({
|
|
1089
1119
|
key: 'map-stories',
|
|
1090
1120
|
flexDirection: 'row',
|
|
@@ -1093,7 +1123,7 @@ export function codeTab(view, { Box, Text, Button, Svg }, on) {
|
|
|
1093
1123
|
children: choices.map((s) =>
|
|
1094
1124
|
Button({
|
|
1095
1125
|
key: `map-story-${s.id}`,
|
|
1096
|
-
label: `${s.id} ${s.title}`,
|
|
1126
|
+
label: s.id === PROJECT ? s.title : `${s.id} ${s.title}`,
|
|
1097
1127
|
...(s.id === storyId ? { variant: 'primary' } : { plain: true }),
|
|
1098
1128
|
onPress: () => on.onMapStory(s.id),
|
|
1099
1129
|
}),
|
|
@@ -1101,9 +1131,28 @@ export function codeTab(view, { Box, Text, Button, Svg }, on) {
|
|
|
1101
1131
|
});
|
|
1102
1132
|
if (!storyId) return [text('No story to map yet: the plan has none open.', { dimColor: true })];
|
|
1103
1133
|
if (!map || map.story?.id !== storyId)
|
|
1104
|
-
return [
|
|
1134
|
+
return [
|
|
1135
|
+
picker,
|
|
1136
|
+
text(storyId === PROJECT ? "Reading the project's code…" : `Reading ${storyId}'s code…`, {
|
|
1137
|
+
dimColor: true,
|
|
1138
|
+
}),
|
|
1139
|
+
];
|
|
1105
1140
|
if (!map.files.length)
|
|
1106
|
-
return [
|
|
1141
|
+
return [
|
|
1142
|
+
picker,
|
|
1143
|
+
text(
|
|
1144
|
+
storyId === PROJECT
|
|
1145
|
+
? 'The project has no code yet.'
|
|
1146
|
+
: `${storyId} hasn't changed any code yet. Whole project shows the code that's there.`,
|
|
1147
|
+
{ dimColor: true },
|
|
1148
|
+
),
|
|
1149
|
+
];
|
|
1150
|
+
// MAP-5: on the project map, a file at work glows on its folder.
|
|
1151
|
+
if (storyId === PROJECT)
|
|
1152
|
+
glow = glowByFolder(
|
|
1153
|
+
glow,
|
|
1154
|
+
map.files.map((f) => f.path),
|
|
1155
|
+
);
|
|
1107
1156
|
const selected = map.files.some((f) => f.path === ui.mapFile) ? ui.mapFile : map.files[0].path;
|
|
1108
1157
|
// The lanes' tints are RGB numbers (the characters' colours); text and SVG take them as hex.
|
|
1109
1158
|
const tints = Object.fromEntries(
|
package/hooks/mod/register.mjs
CHANGED
|
@@ -67,6 +67,10 @@ let reviewTurn = null; // 'next' until the lead's review turn starts, then its i
|
|
|
67
67
|
let approving = null; // the open Approve… confirmation: { request, reason, error }
|
|
68
68
|
let result = null; // what the last merge reported
|
|
69
69
|
let notice = null; // why the person's last act failed
|
|
70
|
+
let teamSince = null; // TEAM-6: since you last looked, as read when the Team tab opened
|
|
71
|
+
let boardSynced = -Infinity; // TEAM-12: when the board was last synced
|
|
72
|
+
let projectRead = -Infinity; // MAP-5: when the whole project's map was last read
|
|
73
|
+
const BOARD_EVERY_MS = 10 * 60000;
|
|
70
74
|
let act = null; // the session's actions, made at session start
|
|
71
75
|
let settingRows = []; // the harness settings, from `code-kit settings --json`
|
|
72
76
|
let pendingSetting = null; // a change waiting for the person's reason: { key, value, reason, error }
|
|
@@ -221,6 +225,15 @@ export function register(on) {
|
|
|
221
225
|
valid && check.adapters?.length
|
|
222
226
|
? ((await json('adapters', '--session', session)) ?? [])
|
|
223
227
|
: [];
|
|
228
|
+
// TEAM-7: the team's claims, intents and pull requests, where the project is a team's.
|
|
229
|
+
const team = valid && check.team ? await json('team') : null;
|
|
230
|
+
// TEAM-12: the plan mirrored onto the team's Project board, at most every 10 minutes, in the
|
|
231
|
+
// background: a slow GitHub never holds up the band.
|
|
232
|
+
const at = await $.clock.now();
|
|
233
|
+
if (team && check.team?.board && at - boardSynced >= BOARD_EVERY_MS) {
|
|
234
|
+
boardSynced = at;
|
|
235
|
+
$.process.run(['node', cli, 'team', 'board'], { cwd, timeoutMs: 120000 }).catch(() => {});
|
|
236
|
+
}
|
|
224
237
|
// The agents at work; none where the session has none bound (claude -p before it mounts).
|
|
225
238
|
const atWork = agentsAtWork(await $.agent.list().catch(() => []));
|
|
226
239
|
model = {
|
|
@@ -232,6 +245,7 @@ export function register(on) {
|
|
|
232
245
|
timeline,
|
|
233
246
|
adapters,
|
|
234
247
|
check,
|
|
248
|
+
team,
|
|
235
249
|
base: status?.base ?? null,
|
|
236
250
|
};
|
|
237
251
|
if (model.state.kind === 'ok') await act.registerReviewer();
|
|
@@ -243,9 +257,19 @@ export function register(on) {
|
|
|
243
257
|
if (model.state?.kind !== 'ok') return;
|
|
244
258
|
if (paneUi.tab === 'code') {
|
|
245
259
|
const id = mapStoryOf(stateNow(), paneUi.selected, paneUi.mapStory);
|
|
246
|
-
|
|
260
|
+
// MAP-5: the whole project reads every code file, so at most every 30 seconds.
|
|
261
|
+
const now = await $.clock.now();
|
|
262
|
+
const fresh =
|
|
263
|
+
id === 'project' && codeMap?.story?.id === 'project' && now - projectRead < 30000;
|
|
264
|
+
if (!fresh) {
|
|
265
|
+
codeMap = id ? ((await json('map', id)) ?? codeMap) : null;
|
|
266
|
+
if (id === 'project') projectRead = now;
|
|
267
|
+
}
|
|
247
268
|
}
|
|
248
269
|
if (paneUi.tab === 'map') trailData = (await json('trail')) ?? trailData;
|
|
270
|
+
// TEAM-6: since you last looked, read once each time the Team tab opens, which marks it read.
|
|
271
|
+
if (paneUi.tab === 'team' && model.check?.team && !teamSince)
|
|
272
|
+
teamSince = (await json('team', 'since')) ?? { people: {} };
|
|
249
273
|
// JOURNAL-1, DECIDE-2: the lineage, for the Journal tab and the decisions' cards.
|
|
250
274
|
const deciding = held.size > 0 || (model.requests?.open ?? []).length > 0;
|
|
251
275
|
if (model.check?.docs?.plan && (['journal', 'decisions'].includes(paneUi.tab) || deciding))
|
|
@@ -543,6 +567,47 @@ export function register(on) {
|
|
|
543
567
|
await $.ui.open({ id: RESULT_ID, title: 'Merge', focus: true, closeOnEscape: true });
|
|
544
568
|
await act.reload();
|
|
545
569
|
},
|
|
570
|
+
// TEAM-9: the person keeps or releases their stale claim; TEAM-5: the tech lead agrees or drops an
|
|
571
|
+
// intent. Each is the person's own press, run as them.
|
|
572
|
+
teamAct: async (args, done) => {
|
|
573
|
+
const ran = await $.process.run(['node', cli, ...args], { cwd, timeoutMs: 60000 });
|
|
574
|
+
notice =
|
|
575
|
+
ran.exitCode === 0
|
|
576
|
+
? null
|
|
577
|
+
: `${done} failed: ${(ran.stderr || ran.stdout).trim().split('\n')[0]}`;
|
|
578
|
+
await act.reload();
|
|
579
|
+
},
|
|
580
|
+
keepClaim: (line) => act.teamAct(['team', 'keep', line.claim.story], 'Keeping the claim'),
|
|
581
|
+
releaseClaim: (line) =>
|
|
582
|
+
act.teamAct(['team', 'release', line.claim.story], 'Releasing the claim'),
|
|
583
|
+
// TEAM-10: agreed, the lead records it where agents read it, through a pull request.
|
|
584
|
+
agreeIntent: async (line) => {
|
|
585
|
+
const { id, about } = line.intent;
|
|
586
|
+
await act.teamAct(
|
|
587
|
+
['intend', 'agree', String(id), '--reason', 'agreed in the code-kit pane'],
|
|
588
|
+
'Agreeing the intent',
|
|
589
|
+
);
|
|
590
|
+
if (notice) return;
|
|
591
|
+
await $.prompt
|
|
592
|
+
.submit({
|
|
593
|
+
text: `Intent #${id} (${about}) was agreed. Record it so every agent builds on it: run node "${cli}" intend record ${id}, then commit the decision record on a branch lead/decision-${id}, push it and open a pull request.`,
|
|
594
|
+
})
|
|
595
|
+
.catch(() => {});
|
|
596
|
+
},
|
|
597
|
+
dropIntent: (line) =>
|
|
598
|
+
act.teamAct(
|
|
599
|
+
['intend', 'drop', String(line.intent.id), '--reason', 'dropped in the code-kit pane'],
|
|
600
|
+
'Dropping the intent',
|
|
601
|
+
),
|
|
602
|
+
// TEAM-7: the Team tab, opened from the band.
|
|
603
|
+
team: async () => {
|
|
604
|
+
paneUi = { ...paneUi, tab: 'team', story: null };
|
|
605
|
+
if (!(await $.ui.panes()).some((p) => p.id === PANE_ID))
|
|
606
|
+
await $.ui.open({ id: PANE_ID, title: 'Lanes', focus: true, closeOnEscape: true });
|
|
607
|
+
await act.readMaps();
|
|
608
|
+
$.ui.invalidate('ui.render');
|
|
609
|
+
return {};
|
|
610
|
+
},
|
|
546
611
|
approvals: async () => {
|
|
547
612
|
await act.reload();
|
|
548
613
|
if (model.state.kind === 'none') return { text: NOT_CODE_KIT };
|
|
@@ -1456,6 +1521,7 @@ export function register(on) {
|
|
|
1456
1521
|
live: liveNow(now),
|
|
1457
1522
|
nextStep: loop.nextStep,
|
|
1458
1523
|
combined: combinedNow(),
|
|
1524
|
+
team: model.team ?? null,
|
|
1459
1525
|
});
|
|
1460
1526
|
|
|
1461
1527
|
on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
|
|
@@ -1473,6 +1539,8 @@ export function register(on) {
|
|
|
1473
1539
|
needs: linesNow(now).filter((l) => !l.mission),
|
|
1474
1540
|
cards: cardsNow(now),
|
|
1475
1541
|
journal: journalNow(),
|
|
1542
|
+
team: model.team ?? null,
|
|
1543
|
+
teamSince,
|
|
1476
1544
|
now,
|
|
1477
1545
|
live: liveNow(now),
|
|
1478
1546
|
held: [...held.values()],
|
|
@@ -1499,6 +1567,7 @@ export function register(on) {
|
|
|
1499
1567
|
$.ui.resolve(e),
|
|
1500
1568
|
{
|
|
1501
1569
|
onTab: (tab) => {
|
|
1570
|
+
if (tab !== 'team') teamSince = null;
|
|
1502
1571
|
paneUi = { ...paneUi, tab };
|
|
1503
1572
|
$.ui.invalidate('ui.render');
|
|
1504
1573
|
act?.readMaps().then(() => $.ui.invalidate('ui.render'));
|