@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.
@@ -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]]);
@@ -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
- return busy?.story.id ?? state?.stories?.find((s) => s.state !== 'done')?.id ?? null;
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. */
@@ -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, so this first merge is yours; after it, the lead merges.'
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 })),
@@ -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
- { dispatch: 'Dispatch', review: 'Review', lead: 'Build', merge: 'Merge' }[step.step] ??
44
- step.step;
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
 
@@ -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
- children: TABS.map((t) =>
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 === 'decisions'
271
- ? decisionsTab(view.cards ?? [], ui, els, on)
272
- : ui.tab === 'journal'
273
- ? journalTab(view.journal ?? [], els, view.now)
274
- : ui.tab === 'code'
275
- ? codeTab(view, els, on)
276
- : ui.tab === 'map'
277
- ? mapTab(view.status, ui.cell, els, on, view.trail, {
278
- surface: view.surface,
279
- columns: view.columns,
280
- })
281
- : ui.tab === 'queue'
282
- ? queueSection(loop, text, Box)
283
- : ui.tab === 'usage'
284
- ? usageSection(view.usage, plan, text, Box)
285
- : [
286
- lanesPane(state, els, view.usage, plan, loop, {
287
- filter: ui.filter,
288
- search: ui.search,
289
- selected: ui.selected,
290
- live: view.live ?? {},
291
- art: view.art ?? null,
292
- detail: selected
293
- ? {
294
- agent: selected.agent ?? null,
295
- owns: view.paths?.[selected.name] ?? [],
296
- stories: laneStories(selected, state),
297
- }
298
- : null,
299
- combined: view.combined ?? null,
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 === 'decisions'
304
- ? 'Nothing needs you. The lead is handling the build.'
305
- : ui.tab === 'journal'
306
- ? 'Nothing recorded yet: the journal starts with the brief.'
307
- : ui.tab === 'queue'
308
- ? 'The merge queue is empty.'
309
- : ui.tab === 'usage'
310
- ? 'No usage measured yet this session.'
311
- : null;
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: requirements.map((r) => ({
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, glow = {}, art = null } = view;
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 choices = (state?.stories ?? []).filter(
1086
- (s) => s.id === storyId || ['in progress', 'review', 'sent back'].includes(s.state),
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 [picker, text(`Reading ${storyId}'s code…`, { dimColor: true })];
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 [picker, text(`${storyId} hasn't changed any code yet.`, { dimColor: true })];
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(
@@ -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
- codeMap = id ? ((await json('map', id)) ?? codeMap) : null;
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'));