@warren-dean/code-kit 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -62,6 +62,8 @@ export const RELAYED = '(given in chat, recorded by the lead)';
62
62
  export const PANE = '(approved in the code-kit pane)';
63
63
  /** Refusals a person's approval would allow, for the mod's band and `code-kit requests`. */
64
64
  export const REQUESTS_FILE = '.claude/state/requests.jsonl';
65
+ /** Every approval given through code-kit, and every one used up, kept for the history (CARD-6). */
66
+ export const GIVEN_FILE = '.claude/state/approvals-given.jsonl';
65
67
 
66
68
  /** The command the lead runs to record approvals a person gave in chat. */
67
69
  /** Marks an approval the lead granted on its own, within `approvals.delegate`; `rule` names the rule. */
@@ -107,11 +109,52 @@ export function approvalHowTo(names, actor, root, allows = 'these edits', config
107
109
  export function grantApprovals(root, names, lane, reason, marks = {}) {
108
110
  const dir = lane ? join(approvalsDir(root), lane) : approvalsDir(root);
109
111
  mkdirSync(dir, { recursive: true });
110
- return names.map((name) => {
112
+ const at = new Date().toISOString();
113
+ const files = names.map((name) => {
111
114
  const file = join(dir, name);
112
115
  writeFileSync(file, `${reason.trim()} ${marks[name] ?? RELAYED}\n`);
113
116
  return file;
114
117
  });
118
+ noteGiven(
119
+ root,
120
+ names.map((name) => ({
121
+ at,
122
+ name,
123
+ ...(lane ? { lane } : {}),
124
+ reason: `${reason.trim()} ${marks[name] ?? RELAYED}`,
125
+ })),
126
+ );
127
+ return files;
128
+ }
129
+
130
+ const givenFile = (root) => join(projectWorktrees(root)[0] ?? root, GIVEN_FILE);
131
+
132
+ function noteGiven(root, entries) {
133
+ try {
134
+ const file = givenFile(root);
135
+ mkdirSync(dirname(file), { recursive: true });
136
+ appendFileSync(file, entries.map((e) => `${JSON.stringify(e)}\n`).join(''));
137
+ } catch {
138
+ // the approval stands; only its history misses it
139
+ }
140
+ }
141
+
142
+ /** The approvals given through code-kit (and those used up), oldest first. */
143
+ export function approvalsGiven(root) {
144
+ try {
145
+ return readFileSync(givenFile(root), 'utf8')
146
+ .split('\n')
147
+ .filter(Boolean)
148
+ .flatMap((l) => {
149
+ try {
150
+ return [JSON.parse(l)];
151
+ } catch {
152
+ return [];
153
+ }
154
+ });
155
+ } catch {
156
+ return [];
157
+ }
115
158
  }
116
159
 
117
160
  /** `path` with symlinks resolved, even if it doesn't exist yet (macOS /var → /private/var). */
@@ -249,8 +292,21 @@ export function useApproval(root, name, actor) {
249
292
  for (const file of [
250
293
  ...(actor?.kind === 'lane' ? [join(dir, actor.lane, name)] : []),
251
294
  join(dir, name),
252
- ])
253
- if (inForce(file)) return rmSync(file, { force: true });
295
+ ]) {
296
+ const a = inForce(file);
297
+ if (!a) continue;
298
+ // Kept in the history, and as the answer to the request it was given for, once its file is gone.
299
+ noteGiven(root, [
300
+ {
301
+ at: a.grantedAt,
302
+ name,
303
+ ...(file !== join(dir, name) ? { lane: actor.lane } : {}),
304
+ reason: a.reason,
305
+ usedAt: new Date().toISOString(),
306
+ },
307
+ ]);
308
+ return rmSync(file, { force: true });
309
+ }
254
310
  }
255
311
 
256
312
  const requestsFile = (root) => join(projectWorktrees(root)[0] ?? root, REQUESTS_FILE);
@@ -283,6 +339,9 @@ export function readRequests(root) {
283
339
  * never changes the refusal, so any failure here is ignored.
284
340
  */
285
341
  export function recordRequest(root, actor, names, what, why, now = Date.now()) {
342
+ // A read-only agent (a reviewer) is never approved to write: what it changed goes back, not to the
343
+ // person as a request.
344
+ if (actor?.kind === 'readonly') return;
286
345
  try {
287
346
  const request = {
288
347
  at: new Date(now).toISOString(),
@@ -307,16 +366,161 @@ export function recordRequest(root, actor, names, what, why, now = Date.now()) {
307
366
  /** Requests from the last 60 minutes that an approval in force doesn't yet answer, newest first. */
308
367
  export function openRequests(root, now = Date.now()) {
309
368
  const open = [];
369
+ const given = approvalsGiven(root);
310
370
  for (const r of readRequests(root).reverse()) {
311
371
  if (now - Date.parse(r.at) >= APPROVAL_MINUTES * 60_000) continue;
312
372
  if (open.some((o) => sameRequest(o, r))) continue;
373
+ // From before read-only agents stopped asking: only the lead and the lanes are ever approved.
374
+ if (!r.lane && r.actor !== 'lead session') continue;
313
375
  const actor = r.lane ? { kind: 'lane', lane: r.lane } : { kind: 'lead' };
314
376
  if (r.names.every((n) => approval(root, n, actor))) continue;
377
+ // Answered already: approved after it was asked (since expired, or used up by the commit it
378
+ // allowed), or the file it named was committed since by someone who could.
379
+ if (r.names.every((n) => grantedSince(root, given, n, r))) continue;
380
+ if (committedSince(root, r)) continue;
315
381
  open.push(r);
316
382
  }
317
383
  return open;
318
384
  }
319
385
 
386
+ /** Whether `name` was approved for the request's lane (or for everyone) at or after it was asked. */
387
+ function grantedSince(root, given, name, r) {
388
+ const asked = Date.parse(r.at);
389
+ if (
390
+ given.some(
391
+ (g) => g.name === name && (!g.lane || g.lane === r.lane) && Date.parse(g.at) >= asked,
392
+ )
393
+ )
394
+ return true;
395
+ const dir = approvalsDir(root);
396
+ return [...(r.lane ? [join(dir, r.lane, name)] : []), join(dir, name)].some((file) => {
397
+ try {
398
+ return statSync(file).isFile() && statSync(file).mtimeMs >= asked;
399
+ } catch {
400
+ return false;
401
+ }
402
+ });
403
+ }
404
+
405
+ /** Whether the file a request named has been committed, on any branch, since it was asked. */
406
+ function committedSince(root, r) {
407
+ const path = String(r.what);
408
+ if (/\s/.test(path) || (!path.includes('/') && !path.includes('.'))) return false;
409
+ try {
410
+ const out = execFileSync(
411
+ 'git',
412
+ [
413
+ '-C',
414
+ root,
415
+ 'log',
416
+ '--all',
417
+ '-1',
418
+ '--format=%ct',
419
+ `--since=@${Math.floor(Date.parse(r.at) / 1000)}`,
420
+ '--',
421
+ path,
422
+ ],
423
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
424
+ );
425
+ return Boolean(out.trim());
426
+ } catch {
427
+ return false;
428
+ }
429
+ }
430
+
431
+ /**
432
+ * CARD-6: every approval given, newest first: { at, name, lane, reason, by, files, branches, used,
433
+ * expired }. From the approvals given through code-kit, the approvals on disk (a person's own `!` echo
434
+ * included), and the audit log in every worktree, so an approval shows before its branch merges.
435
+ */
436
+ export function approvalHistory(root, now = Date.now()) {
437
+ const grants = [];
438
+ const near = (a, b) => Math.abs(Date.parse(a) - Date.parse(b)) < 2000;
439
+ const add = (g) => {
440
+ const found = grants.find(
441
+ (x) => x.name === g.name && (x.lane ?? null) === (g.lane ?? null) && near(x.at, g.at),
442
+ );
443
+ if (found) {
444
+ if (g.usedAt) found.used = true;
445
+ return found;
446
+ }
447
+ const grant = {
448
+ at: g.at,
449
+ name: g.name,
450
+ lane: g.lane ?? null,
451
+ reason: g.reason ?? '',
452
+ files: [],
453
+ branches: [],
454
+ used: Boolean(g.usedAt),
455
+ };
456
+ grants.push(grant);
457
+ return grant;
458
+ };
459
+ for (const g of approvalsGiven(root)) add(g);
460
+ const dir = approvalsDir(root);
461
+ const look = (folder, lane) => {
462
+ let names = [];
463
+ try {
464
+ names = readdirSync(folder);
465
+ } catch {
466
+ return;
467
+ }
468
+ for (const name of names) {
469
+ const file = join(folder, name);
470
+ const st = statSync(file);
471
+ if (st.isDirectory()) {
472
+ if (!lane) look(file, name);
473
+ continue;
474
+ }
475
+ add({
476
+ at: new Date(st.mtimeMs).toISOString(),
477
+ name,
478
+ lane,
479
+ reason: readFileSync(file, 'utf8').trim(),
480
+ });
481
+ }
482
+ };
483
+ look(dir, null);
484
+ const seen = new Set();
485
+ for (const tree of projectWorktrees(root).length ? projectWorktrees(root) : [root]) {
486
+ let lines = [];
487
+ try {
488
+ lines = readFileSync(join(tree, APPROVAL_LOG), 'utf8').split('\n').filter(Boolean);
489
+ } catch {
490
+ continue;
491
+ }
492
+ for (const line of lines) {
493
+ if (seen.has(line)) continue;
494
+ seen.add(line);
495
+ let e;
496
+ try {
497
+ e = JSON.parse(line);
498
+ } catch {
499
+ continue;
500
+ }
501
+ if (!e.approval || e.approval === 'lead') continue;
502
+ const grant =
503
+ grants.find((x) => x.name === e.approval && near(x.at, e.grantedAt ?? e.at)) ??
504
+ add({ at: e.grantedAt ?? e.at, name: e.approval, reason: e.reason });
505
+ if (e.file && !grant.files.includes(e.file)) grant.files.push(e.file);
506
+ if (e.branch && !grant.branches.includes(e.branch)) grant.branches.push(e.branch);
507
+ }
508
+ }
509
+ return grants
510
+ .map((g) => ({
511
+ ...g,
512
+ by: /within the delegated rules/.test(g.reason)
513
+ ? 'the lead, within your delegated rules'
514
+ : /given in chat, recorded by the lead/.test(g.reason)
515
+ ? 'you, in chat'
516
+ : /in the [\w -]+ pane/.test(g.reason)
517
+ ? 'you, in the pane'
518
+ : 'you',
519
+ expired: g.used || now - Date.parse(g.at) >= approvalMinutes(g.name) * 60_000,
520
+ }))
521
+ .sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
522
+ }
523
+
320
524
  /** Approvals in force now: [{ name, lane?, reason, grantedAt, minutesLeft }]. */
321
525
  export function approvalsInForce(root, now = Date.now()) {
322
526
  const dir = approvalsDir(root);
@@ -434,6 +638,8 @@ export function writeProblem(actor, rel, root, config) {
434
638
  // A package a person approved may change the manifests and lockfiles its install rewrites.
435
639
  if (dependencyChange(actor, rel, root, config)) return null;
436
640
  const guarded = protectedEntry(rel, config);
641
+ if (actor.kind === 'readonly' && (guarded || !matchesAny(rel, config.anyActor)))
642
+ return readonlyProblem(actor, rel);
437
643
  // The lead writes protected files without an approval: the person reviews them in the pull request,
438
644
  // and every committed change is in the audit log. Anyone else needs a person's approval as well.
439
645
  if (guarded && actor.kind !== 'lead' && !approval(root, guarded.approval, actor)) {
@@ -441,12 +647,16 @@ export function writeProblem(actor, rel, root, config) {
441
647
  return `${rel} is ${guarded.why} and needs a person's approval. Ask the lead (a person) to run\n${approvalHowTo(guarded.approval, actor, root, undefined, config)}`;
442
648
  }
443
649
  if (matchesAny(rel, config.anyActor)) return null;
444
- if (actor.kind === 'readonly') return `${actor.label} is read-only; it may not write ${rel}.`;
650
+ if (actor.kind === 'readonly') return readonlyProblem(actor, rel);
445
651
  if (!owns(actor, rel, config)) return ownershipProblem(actor, rel, config);
446
652
  const specs = actor.kind === 'lane' && config.docs?.specs;
447
653
  return (specs && specCheckProblem(rel, root)) || screenProblem(rel, root, config);
448
654
  }
449
655
 
656
+ /** A read-only agent's write, refused, with how to undo one a command it ran made (a build, a generator). */
657
+ const readonlyProblem = (actor, rel) =>
658
+ `${actor.label} is read-only; it may not write ${rel}. If a command you ran changed it (a build, a formatter or a code generator), put it back with git restore -- ${rel} (or delete it if it's new), and run such commands only in a throwaway worktree of the branch.`;
659
+
450
660
  function outsideAllowed(actor, rel, root, config) {
451
661
  if (actor.kind !== 'lead') return false;
452
662
  const target = resolve(root, rel);
@@ -60,9 +60,16 @@ export function teamProblems(c) {
60
60
  if (
61
61
  t.provider !== undefined &&
62
62
  t.provider !== 'github' &&
63
- !(isObj(t.provider) && typeof t.provider.command === 'string' && t.provider.command.trim())
63
+ !(
64
+ isObj(t.provider) &&
65
+ typeof t.provider.command === 'string' &&
66
+ t.provider.command.trim() &&
67
+ (t.provider.fallback === undefined || t.provider.fallback === 'github')
68
+ )
64
69
  )
65
- p.push('"team.provider" must be "github" or { "command": "<the provider\'s command>" }');
70
+ p.push(
71
+ '"team.provider" must be "github" or { "command": "<the provider\'s command>", "fallback"?: "github" }',
72
+ );
66
73
  }
67
74
  for (const [name, lane] of Object.entries(isObj(c.lanes) ? c.lanes : {})) {
68
75
  if (!isObj(lane) || lane.owners === undefined) continue;
@@ -148,11 +155,44 @@ function claimOf(message) {
148
155
  }
149
156
  }
150
157
 
151
- /** The provider for this project's team: GitHub's, or the config's command. */
158
+ /**
159
+ * The provider for this project's team: GitHub's, or the config's command. A command with
160
+ * `fallback: "github"` (a hosted provider, such as the code-kit teams hub) hands each operation it
161
+ * can't reach to the GitHub provider, so claims are still checked while it's down (TEAM-8).
162
+ */
152
163
  export function teamProvider(root, team) {
153
- return team.provider === 'github'
154
- ? githubProvider(root, team)
155
- : commandProvider(root, team, team.provider.command);
164
+ if (team.provider === 'github') return githubProvider(root, team);
165
+ const command = commandProvider(root, team, team.provider.command);
166
+ if (team.provider.fallback !== 'github') return command;
167
+ const github = githubProvider(root, team);
168
+ const down = (r) => Boolean(r?.unreachable) || r?.reachable === false;
169
+ const either =
170
+ (op) =>
171
+ (...args) => {
172
+ const r = command[op](...args);
173
+ if (!down(r) && r !== null) return r;
174
+ const g = github[op](...args);
175
+ // The state says it came through GitHub, and why, for the band.
176
+ return op === 'state' && g
177
+ ? { ...g, fallback: r?.why ?? "The team provider can't be reached." }
178
+ : g;
179
+ };
180
+ return {
181
+ ...Object.fromEntries(
182
+ [
183
+ 'whoami',
184
+ 'claim',
185
+ 'release',
186
+ 'state',
187
+ 'intend',
188
+ 'settle',
189
+ 'reassign',
190
+ 'intent',
191
+ 'since',
192
+ ].map((op) => [op, either(op)]),
193
+ ),
194
+ forget: () => (command.forget(), github.forget()),
195
+ };
156
196
  }
157
197
 
158
198
  function githubProvider(root, team) {
@@ -402,11 +442,26 @@ function githubProvider(root, team) {
402
442
  'number,title,author,headRefName,isDraft,updatedAt,url',
403
443
  ]) ?? 'null',
404
444
  );
445
+ const who = me();
405
446
  const state = {
406
447
  at: new Date().toISOString(),
407
448
  reachable: Boolean(claims),
408
449
  github: Boolean(intents && pulls),
409
- me: me(),
450
+ me: who,
451
+ // What coordinates the team, for the Team pane: GitHub itself, through refs and discussions.
452
+ provider: {
453
+ name: 'GitHub',
454
+ live: false,
455
+ detail: `claims are refs on origin, intents are discussions in "${team.intents}"`,
456
+ },
457
+ // TEAM-13: what the team still needs set up for its claims and intents to work.
458
+ setup: [
459
+ ...(who ? [] : ["gh isn't signed in on this machine: run gh auth login."]),
460
+ ...(claims ? [] : ["The shared repository (origin) can't be reached."]),
461
+ ...(found && !found.category
462
+ ? [noCategory(found).replace(/, then raise the intent again\.$/, '.')]
463
+ : []),
464
+ ],
410
465
  techLead: team.techLead,
411
466
  owners: team.owners,
412
467
  staleDays: team.staleDays,
@@ -2,6 +2,7 @@
2
2
  // the person, each with its lineage. The band's "you" lines say what needs them; a decision card says why:
3
3
  // what they asked for, the story and requirements it belongs to, what the agent was doing, what was already
4
4
  // settled, why the lead couldn't settle this itself, and each option with what happens next.
5
+ import { askWords } from './view.mjs';
5
6
 
6
7
  /** The kinds of band line that are the person's to decide; the rest are notes. */
7
8
  export const DECISION_KINDS = new Set([
@@ -16,6 +17,7 @@ export const DECISION_KINDS = new Set([
16
17
  'teamCollision',
17
18
  'teamIntent',
18
19
  'teamReview',
20
+ 'teamDrift',
19
21
  ]);
20
22
 
21
23
  /** What each answer does, in the person's words. */
@@ -72,6 +74,8 @@ function whyOf(line, check) {
72
74
  if (line.kind === 'teamIntent')
73
75
  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
76
  if (line.kind === 'teamReview') return "It's in a lane you own, so its review is yours.";
77
+ if (line.kind === 'teamDrift')
78
+ return "The team's drift check found your work building on something that has since changed, or heading into someone else's: settle it before it merges.";
75
79
  return null;
76
80
  }
77
81
 
@@ -148,7 +152,7 @@ export function decisionCards(lines, context = {}) {
148
152
  })),
149
153
  doing:
150
154
  line.held?.command ??
151
- line.request?.what ??
155
+ (line.request ? askWords(line.request, line.files ?? [line.request.what]) : null) ??
152
156
  (line.proposal?.label ? `Next step: ${line.proposal.label}` : null) ??
153
157
  (line.intent
154
158
  ? `Intent #${line.intent.id}: ${line.intent.about}${line.intent.touches.length ? ` (touches ${line.intent.touches.join(', ')})` : ''}`
@@ -14,6 +14,55 @@ const LEAD_STEPS = new Set([
14
14
  'update',
15
15
  ]);
16
16
 
17
+ /** The steps that name stories, which an agent already at work on one shouldn't be asked to start again. */
18
+ const STORY_STEPS = new Set(['dispatch', 'review', 'lead', 'design']);
19
+ const AT_WORK = new Set(['pending', 'running', 'waiting']);
20
+
21
+ /**
22
+ * LOOP-9: the stories agents are at work on now, by the task each was started with: a lane agent is
23
+ * started as "ST-12 booking form", a reviewer as "Review web/st-12". `code-kit next` reads only the
24
+ * branches, so it can't tell a branch still being built, or already under review, from one waiting.
25
+ */
26
+ export function storiesInFlight(agents) {
27
+ const ids = new Set();
28
+ for (const a of agents ?? []) {
29
+ if (!AT_WORK.has(a.status)) continue;
30
+ const text = `${a.description ?? ''}`;
31
+ for (const m of text.matchAll(/\bST-(\d+)\b/gi)) ids.add(`ST-${m[1]}`);
32
+ for (const m of text.matchAll(/\b[\w-]+\/st-(\d+)\b/gi)) ids.add(`ST-${m[1]}`);
33
+ }
34
+ return ids;
35
+ }
36
+
37
+ /**
38
+ * LOOP-9: the step from `code-kit next`, without the stories already in flight. A step left with none
39
+ * gives way to the next one `next` listed; with nothing left, the loop waits, saying for what, until an
40
+ * agent finishes (which moves the project on).
41
+ */
42
+ export function withoutInFlight(step, inFlight) {
43
+ if (!step || !inFlight?.size) return step;
44
+ const chain = [step, ...(step.then ?? [])].map(({ then: _then, ...s }) => s);
45
+ const kept = [];
46
+ const busy = [];
47
+ for (const s of chain) {
48
+ if (!STORY_STEPS.has(s.step)) {
49
+ kept.push(s);
50
+ continue;
51
+ }
52
+ const ids = (s.args ?? '').split(/\s+/).filter(Boolean);
53
+ const free = ids.filter((id) => !inFlight.has(id.toUpperCase()));
54
+ busy.push(...ids.filter((id) => inFlight.has(id.toUpperCase())));
55
+ if (free.length) kept.push({ ...s, args: free.join(' ') });
56
+ }
57
+ if (kept.length) return { ...kept[0], ...(kept.length > 1 ? { then: kept.slice(1) } : {}) };
58
+ const names = [...new Set(busy)];
59
+ return {
60
+ step: 'wait',
61
+ why: `${names.join(', ')} ${names.length === 1 ? 'is' : 'are'} still with ${names.length === 1 ? 'its agent' : 'their agents'}; the loop goes on when one finishes`,
62
+ inFlight: names,
63
+ };
64
+ }
65
+
17
66
  /**
18
67
  * The prompt for a step from `code-kit next --json` (LOOP-1), or null when it isn't the lead's to
19
68
  * take. Every ready story is named: the dispatch skill decides how many lanes go (LOOP-2). A prompt
@@ -1,7 +1,6 @@
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';
5
4
  import { trailRows as trailRowsDrawn, trailSvg } from './trail.mjs';
6
5
  import {
7
6
  MAP_LEGEND,
@@ -32,7 +31,6 @@ export const TABS = [
32
31
  { id: 'map', label: 'Trail', hotkey: '5' },
33
32
  { id: 'queue', label: 'Queue', hotkey: '6' },
34
33
  { id: 'usage', label: 'Usage', hotkey: '7' },
35
- { id: 'team', label: 'Team', hotkey: '8' },
36
34
  ];
37
35
 
38
36
  /** The pane's own state, kept by the mod between draws. */
@@ -246,8 +244,7 @@ export function lanesFrame(view, els, on) {
246
244
  key: 'tabs',
247
245
  flexDirection: 'row',
248
246
  columnGap: 1,
249
- // The Team tab is there only for a team (TEAM-7).
250
- children: TABS.filter((t) => t.id !== 'team' || view.team).map((t) =>
247
+ children: TABS.map((t) =>
251
248
  Button({
252
249
  key: `tab-${t.id}`,
253
250
  label: t.label,
@@ -272,52 +269,48 @@ export function lanesFrame(view, els, on) {
272
269
 
273
270
  const selected = state.lanes.find((l) => l.name === ui.selected) ?? null;
274
271
  const body =
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
- ];
272
+ ui.tab === 'decisions'
273
+ ? decisionsTab(view.cards ?? [], ui, els, on)
274
+ : ui.tab === 'journal'
275
+ ? journalTab(view.journal ?? [], els, view.now)
276
+ : ui.tab === 'code'
277
+ ? codeTab(view, els, on)
278
+ : ui.tab === 'map'
279
+ ? mapTab(view.status, ui.cell, els, on, view.trail, {
280
+ surface: view.surface,
281
+ columns: view.columns,
282
+ })
283
+ : ui.tab === 'queue'
284
+ ? queueSection(loop, text, Box)
285
+ : ui.tab === 'usage'
286
+ ? usageSection(view.usage, plan, text, Box)
287
+ : [
288
+ lanesPane(state, els, view.usage, plan, loop, {
289
+ filter: ui.filter,
290
+ search: ui.search,
291
+ selected: ui.selected,
292
+ live: view.live ?? {},
293
+ art: view.art ?? null,
294
+ detail: selected
295
+ ? {
296
+ agent: selected.agent ?? null,
297
+ owns: view.paths?.[selected.name] ?? [],
298
+ stories: laneStories(selected, state),
299
+ }
300
+ : null,
301
+ combined: view.combined ?? null,
302
+ }),
303
+ ];
309
304
  const empty =
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;
305
+ ui.tab === 'decisions'
306
+ ? 'Nothing needs you. The lead is handling the build.'
307
+ : ui.tab === 'journal'
308
+ ? 'Nothing recorded yet: the journal starts with the brief.'
309
+ : ui.tab === 'queue'
310
+ ? 'The merge queue is empty.'
311
+ : ui.tab === 'usage'
312
+ ? 'No usage measured yet this session.'
313
+ : null;
321
314
 
322
315
  // VIEW-6: j/k move between lanes; the selected lane's acts; f filters.
323
316
  const opening = selected ? storyToOpen(selected, state) : null;