@warren-dean/code-kit 0.9.1 → 0.11.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.
@@ -9,6 +9,8 @@ import { buildStatus, readStories, DESIGN, LEAD } from './plan.mjs';
9
9
  import { pendingSetup } from './adapters/index.mjs';
10
10
  import { CONFIG_FILE, SETUP_VERSION } from './config.mjs';
11
11
  import { readQueue, waiting } from './queue.mjs';
12
+ import { laneOf } from './rules.mjs';
13
+ import { SECURITY_AGENT_FILE } from './security.mjs';
12
14
 
13
15
  export const DRAFT_FILE = '.claude/code-kit.draft.json';
14
16
  export const BRIEF_STATES = ['framed', 'explored', 'scoped', 'specified', 'planned', 'ready'];
@@ -149,11 +151,17 @@ export function nextStep(root, base) {
149
151
  }
150
152
  const [setup] = pendingSetup(raw, root);
151
153
  if (setup) return result('setup', setup.why, { args: setup.skill });
152
- // ONB-5: a project set up before setup did all of this is told what re-running init adds.
153
- if ((raw?.setup ?? 1) < SETUP_VERSION)
154
+ // ONB-5: a project set up before setup did all of this is told what re-running init adds; when
155
+ // ONB-6 finds what it's missing, no new story starts until init has added it.
156
+ const older = (raw?.setup ?? 1) < SETUP_VERSION;
157
+ const gaps = older ? setupGaps(config, readStories(root, plan), files) : [];
158
+ if (older && !gaps.length)
154
159
  attention.push(
155
- "This project's code-kit setup predates what init now sets up: project skills with each lane agent, designs approved before screens are built, a QA lane for end-to-end tests, and companion tools set up before coding. Re-run /code-kit:init to add what's missing; it keeps everything that's still right and shows you the difference first.",
160
+ "This project's code-kit setup predates what init now sets up: project skills with each lane agent, designs approved before screens are built, a QA lane for end-to-end tests, a security reviewer that checks each sensitive change as an attacker would, and companion tools set up before coding. Re-run /code-kit:init to add what's missing; it keeps everything that's still right and shows you the difference first.",
156
161
  );
162
+ const missingWords = gaps.length
163
+ ? `This project's setup is missing what init now sets up: ${gaps.join('; ')}.`
164
+ : '';
157
165
  // ONB-1: a lane agent naming a skill that doesn't exist works without the recipe it was given.
158
166
  for (const missing of missingSkills(root)) attention.push(missing);
159
167
 
@@ -194,6 +202,16 @@ export function nextStep(root, base) {
194
202
  // ONB-2: design stories, designed with the person before any screen that depends on them is built.
195
203
  const forDesign = readyStories.filter((s) => buildable(s) && s.lane === DESIGN).map((s) => s.id);
196
204
  const unbuildable = readyStories.filter((s) => !buildable(s)).map((s) => s.id);
205
+ // ONB-6: work already started finishes; nothing new starts until init has filled the gaps.
206
+ const held = gaps.length
207
+ ? [...ready, ...forDesign, ...forLead].sort((a, b) => a.slice(3) - b.slice(3))
208
+ : [];
209
+ if (held.length) {
210
+ ready.length = forDesign.length = forLead.length = 0;
211
+ attention.push(
212
+ `${missingWords} ${held.join(', ')} ${held.length === 1 ? 'waits' : 'wait'} until /code-kit:init has added it.`,
213
+ );
214
+ }
197
215
  if (unbuildable.length)
198
216
  attention.push(
199
217
  `${unbuildable.join(', ')} can't be dispatched until the plan's gaps are fixed.`,
@@ -262,6 +280,12 @@ export function nextStep(root, base) {
262
280
  `${forLead.join(', ')} ${forLead.length === 1 ? 'is' : 'are'} the lead's own: build ${forLead.length === 1 ? 'it' : 'them'} on ${forLead.map((id) => `lead/${id.toLowerCase()}`).join(', ')}, then review like any branch.`,
263
281
  { args: forLead.join(' ') },
264
282
  );
283
+ // ONB-6: with nothing in flight, the setup is brought up to date before the next story.
284
+ if (gaps.length && !building.length && !queued.length)
285
+ return result(
286
+ 'update',
287
+ `${missingWords} Re-run /code-kit:init before the next story: it shows the person the difference before anything changes.`,
288
+ );
265
289
  if (building.length)
266
290
  return result(
267
291
  'wait',
@@ -294,6 +318,66 @@ export function nextStep(root, base) {
294
318
  );
295
319
  }
296
320
 
321
+ const SCREEN_LANE = /(^|-)(web|mobile|frontend|front-end|ui|desktop|ios|android|admin|portal)(-|$)/;
322
+ const SCREEN_FILE = /\.(tsx|jsx|vue|svelte|swift|dart)$/;
323
+ const TEST_FILE = /(^|\/)(__tests__|e2e|tests?)\/|\.(test|spec|stories)\.\w+$/;
324
+ const QA_LANE = /(^|-)(qa|e2e|tests?)(-|$)/;
325
+
326
+ /**
327
+ * ONB-6: what a setup older than init's is missing, in words, from the config, the plan and the code.
328
+ * A lane owns screens when its name or agent says so (web, mobile, desktop…) or it holds screen
329
+ * files. It's missing the design gate, design stories before the screen stories not yet started, and a
330
+ * QA lane for the end-to-end suites; and, with or without screens, the security reviewer (ONB-7), when
331
+ * its agent file isn't among the tracked files.
332
+ */
333
+ export function setupGaps(config, stories, files = []) {
334
+ const gaps = screenGaps(config, stories, files);
335
+ if (!files.includes(SECURITY_AGENT_FILE))
336
+ gaps.push(
337
+ `a security reviewer (${SECURITY_AGENT_FILE}) that checks each change touching sign-in, permissions, personal data, payments, secrets or dependencies as an attacker would`,
338
+ );
339
+ return gaps;
340
+ }
341
+
342
+ /** ONB-6: the gaps of a setup whose lanes own screens: the design gate, design stories, a QA lane. */
343
+ function screenGaps(config, stories, files) {
344
+ const lanes = Object.entries(config.lanes);
345
+ const holdsScreens = new Set(
346
+ files.filter((f) => SCREEN_FILE.test(f) && !TEST_FILE.test(f)).map((f) => laneOf(f, config)),
347
+ );
348
+ const screens = lanes
349
+ .filter(
350
+ ([name, l]) => SCREEN_LANE.test(name) || SCREEN_LANE.test(l.agent) || holdsScreens.has(name),
351
+ )
352
+ .map(([name]) => name);
353
+ if (!screens.length) return [];
354
+ const gaps = [];
355
+ const list = (names) => names.join(', ').replace(/, ([^,]*)$/, ' and $1');
356
+ if (!config.designGate)
357
+ gaps.push(
358
+ `a design gate for ${list(screens)}, so screens are built only from approved designs`,
359
+ );
360
+ // A screen story not yet started, with no design story among everything it depends on.
361
+ const byId = new Map(stories.map((s) => [s.id, s]));
362
+ const designed = (s, seen = new Set()) =>
363
+ s.dependsOn.some((d) => {
364
+ if (seen.has(d)) return false;
365
+ seen.add(d);
366
+ const dep = byId.get(d);
367
+ return dep && (dep.lane === DESIGN || designed(dep, seen));
368
+ });
369
+ const undesigned = stories
370
+ .filter((s) => screens.includes(s.lane) && ['todo', 'ready'].includes(s.status) && !designed(s))
371
+ .map((s) => s.id);
372
+ if (undesigned.length)
373
+ gaps.push(
374
+ `design stories before the screens: ${list(undesigned)} ${undesigned.length === 1 ? 'is' : 'are'} planned with no design to build from`,
375
+ );
376
+ if (!lanes.some(([name, l]) => QA_LANE.test(name) || QA_LANE.test(l.agent)))
377
+ gaps.push('a QA lane with a test-engineer for the end-to-end tests of the journeys');
378
+ return gaps;
379
+ }
380
+
297
381
  /** The code-kit skills every agent may name without a project copy. */
298
382
  const KIT_SKILLS = (() => {
299
383
  try {
@@ -20,7 +20,8 @@ export const DESIGN = 'design';
20
20
  const TEST =
21
21
  /(^|\/)(tests?|__tests__|e2e)\/|\.(test|spec)\.[^/]+$|_test\.(go|py)$|(^|\/)test_[^/]+\.py$/;
22
22
 
23
- const markdownFiles = (root, path) => {
23
+ /** The markdown files at `path`: the file itself, or every one under the folder, sorted. */
24
+ export const markdownFiles = (root, path) => {
24
25
  const full = join(root, path);
25
26
  if (!existsSync(full)) return [];
26
27
  if (statSync(full).isFile()) return path.endsWith('.md') ? [path] : [];
@@ -0,0 +1,180 @@
1
+ // ONB-7: which branches the security reviewer looks at. Security is a reviewer, not a lane: it owns no
2
+ // paths and checks everyone's work. A story's change gets the security review when it touches sign-in,
3
+ // permissions, outside input, personal data, encryption, payments, secrets or commands built from
4
+ // strings, adds dependencies, or cites a requirement from a spec's `## Security requirements`. Read
5
+ // from git (base...branch): the files changed and the lines added, tests and docs left out.
6
+ import { execFileSync } from 'node:child_process';
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { isDependencyFile } from './dependencies.mjs';
10
+ import { markdownFiles, readStories } from './plan.mjs';
11
+ import { storyOfBranch } from './teamfacts.mjs';
12
+
13
+ export const SECURITY_AGENT = 'security-reviewer';
14
+ export const SECURITY_AGENT_FILE = `.claude/agents/${SECURITY_AGENT}.md`;
15
+
16
+ // Tests, docs and design files describe the change; the review looks at the code that ships.
17
+ const NOT_SHIPPED =
18
+ /(^|\/)(tests?|__tests__|e2e|docs?|design)\/|\.(test|spec|stories)\.[^/]+$|_test\.(go|py)$|(^|\/)test_[^/]+\.py$|\.(md|mdx|txt|png|jpe?g|gif|svg|webp)$/i;
19
+
20
+ /**
21
+ * What makes a change sensitive, by kind: `path` matches a changed file's path, `code` (one pattern or
22
+ * several) an added line.
23
+ * Each kind reads as the reason a person is given.
24
+ */
25
+ export const SENSITIVE = [
26
+ {
27
+ kind: 'sign-in',
28
+ why: 'sign-in or sessions',
29
+ path: /(^|[/_.-])(auth\w*|login|logout|sign-?in|sign-?up|sessions?|oauth|sso|jwt|passwords?|mfa|otp)([/_.-]|$)/i,
30
+ code: /\b(signIn|signOut|login|logout|getSession|setSession|jwt\.(sign|verify)|jsonwebtoken|passport\.|bcrypt|argon2|verifyPassword|hashPassword|Set-Cookie|cookies\(\)|httpOnly)\b/,
31
+ },
32
+ {
33
+ kind: 'permissions',
34
+ why: 'permissions',
35
+ path: /(^|[/_.-])(permissions?|roles?|rbac|acl|polic(y|ies)|guards?|middleware|authori[sz]\w*)([/_.-]|$)/i,
36
+ code: [
37
+ /\b(isAdmin|hasRole|hasPermission|can[A-Z]\w*|[Aa]uthori[sz]e\w*|requireRole)\b/,
38
+ /\b(enable row level security|create policy|grant\s+\w+\s+on)\b/i,
39
+ ],
40
+ },
41
+ {
42
+ kind: 'input',
43
+ why: 'input from outside (requests, forms, uploads, webhooks)',
44
+ path: /(^|[/_.-])(webhooks?|uploads?|handlers?)([/_.-]|$)|(^|\/)(api|routes?)\/|(^|\/)route\.[jt]s$/i,
45
+ code: /\b(req\.(body|query|params|files?)|request\.(json|form|args|body)\(?|formData\(\)|searchParams|FormData|multer|busboy|ctx\.request\.body|@Body\(|@Query\(|@Param\()/,
46
+ },
47
+ {
48
+ kind: 'personal data',
49
+ why: 'personal data',
50
+ path: /(^|[/_.-])(users?|profiles?|accounts?|customers?|patients?|members?|children|students?|contacts?)([/_.-]|$)/i,
51
+ code: /\b(email|phone(_?number)?|date_?of_?birth|dob|home_?address|street_?address|passport|tax_?file|national_?id|medicare|ssn|first_?name|last_?name|full_?name)\b/i,
52
+ },
53
+ {
54
+ kind: 'encryption',
55
+ why: 'encryption, hashing or random values',
56
+ path: /(^|[/_.-])(crypto|encrypt\w*|decrypt\w*|ciphers?|hash(ing)?|keys?|kms|signing)([/_.-]|$)/i,
57
+ code: /\b(createCipheriv|createDecipheriv|createHash|createHmac|randomBytes|Math\.random|crypto\.subtle|md5|sha1|AES|RSA|hashlib|Fernet)\b/,
58
+ },
59
+ {
60
+ kind: 'payments',
61
+ why: 'payments',
62
+ path: /(^|[/_.-])(payments?|billing|checkout|invoices?|stripe|subscriptions?|refunds?|payouts?|wallet)([/_.-]|$)/i,
63
+ code: /\b(stripe|paymentIntent|checkout\.sessions|charge|refund|payout|amount_?cents)\b/i,
64
+ },
65
+ {
66
+ kind: 'secrets',
67
+ why: 'secrets or credentials',
68
+ path: /(^|\/)\.env(\.|$)|(^|[/_.-])(secrets?|credentials?|tokens?)([/_.-]|$)/i,
69
+ code: /\b(api_?key|secret_?key|client_?secret|private_?key|access_?token|refresh_?token|password\s*[:=])/i,
70
+ },
71
+ {
72
+ kind: 'injection',
73
+ why: 'queries, commands, templates or redirects built from strings',
74
+ code: /(\$queryRawUnsafe|\$executeRawUnsafe|\.raw\(|sql\.unsafe|execute\(\s*[`'"]?\s*(select|insert|update|delete)\b.*(\$\{|\+)|child_process|\bexec(Sync)?\(|\bspawn(Sync)?\(|subprocess\.|os\.system|\beval\(|new Function\(|dangerouslySetInnerHTML|\.innerHTML\s*=|v-html|\{\{\{|pickle\.loads|yaml\.load\(|unserialize\(|res\.redirect\(|redirect\(\s*(req|request|params|searchParams)|fetch\(\s*(req|request|params|body|url)\b)/i,
75
+ },
76
+ ];
77
+
78
+ /**
79
+ * The requirement IDs of each spec's `## Security requirements` section (spec-design writes a spec's
80
+ * security requirements there, as `### AREA-n` headings like any other requirement).
81
+ */
82
+ export function securityRequirementIds(texts) {
83
+ const ids = new Set();
84
+ for (const text of texts) {
85
+ let inSecurity = false;
86
+ for (const line of text.split('\n')) {
87
+ if (/^##\s/.test(line)) inSecurity = /^##\s+Security requirements\b/i.test(line);
88
+ const m = inSecurity && line.match(/^###\s+((?!ST-)[A-Z][A-Z0-9]*-\d+)\b/);
89
+ if (m) ids.add(m[1]);
90
+ }
91
+ }
92
+ return ids;
93
+ }
94
+
95
+ /** Added lines of a unified diff (`git diff -U0`): [{ file, line, text }]. */
96
+ export function addedLines(diff) {
97
+ const added = [];
98
+ let file = null;
99
+ let line = 0;
100
+ for (const l of diff.split('\n')) {
101
+ if (l.startsWith('+++ ')) {
102
+ file = l === '+++ /dev/null' ? null : l.slice(4).replace(/^b\//, '');
103
+ continue;
104
+ }
105
+ const hunk = l.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
106
+ if (hunk) {
107
+ line = Number(hunk[1]);
108
+ continue;
109
+ }
110
+ if (!file) continue;
111
+ if (l.startsWith('+')) added.push({ file, line: line++, text: l.slice(1) });
112
+ else if (l.startsWith(' ')) line++;
113
+ }
114
+ return added;
115
+ }
116
+
117
+ const examples = (list) =>
118
+ `${list.slice(0, 3).join(', ')}${list.length > 3 ? ` and ${list.length - 3} more` : ''}`;
119
+
120
+ /**
121
+ * Why a change needs the security review, in words, one per kind: [{ kind, why, where: [...] }].
122
+ * Empty when it touches nothing sensitive. `files` are the changed files, `added` the added lines,
123
+ * `cites` the story's requirements and `security` the IDs of the specs' security requirements.
124
+ */
125
+ export function securityReasons({ files = [], added = [], cites = [], security = new Set() }) {
126
+ const reasons = [];
127
+ const shipped = files.filter((f) => !NOT_SHIPPED.test(f));
128
+ const lines = added.filter((a) => !NOT_SHIPPED.test(a.file));
129
+ for (const s of SENSITIVE) {
130
+ const code = [s.code].flat();
131
+ const where = [
132
+ ...(s.path ? shipped.filter((f) => s.path.test(f)) : []),
133
+ ...lines.filter((a) => code.some((c) => c.test(a.text))).map((a) => `${a.file}:${a.line}`),
134
+ ];
135
+ if (where.length) reasons.push({ kind: s.kind, why: s.why, where: [...new Set(where)] });
136
+ }
137
+ const deps = files.filter(isDependencyFile);
138
+ if (deps.length) reasons.push({ kind: 'dependencies', why: 'dependencies', where: deps });
139
+ const cited = cites.filter((id) => security.has(id));
140
+ if (cited.length)
141
+ reasons.push({ kind: 'requirements', why: 'security requirements it cites', where: cited });
142
+ return reasons;
143
+ }
144
+
145
+ /** The reasons as one sentence: "sign-in or sessions (src/auth/login.ts), dependencies (package.json)". */
146
+ export const reasonsInWords = (reasons) =>
147
+ reasons.map((r) => `${r.why} (${examples(r.where)})`).join('; ');
148
+
149
+ const git = (root, ...args) =>
150
+ execFileSync('git', ['-C', root, ...args], {
151
+ encoding: 'utf8',
152
+ stdio: ['ignore', 'pipe', 'pipe'],
153
+ maxBuffer: 64 * 1024 * 1024,
154
+ });
155
+
156
+ /**
157
+ * Whether `branch` needs the security review against `base`, read from git:
158
+ * { branch, story, needed, reasons, agent } where `agent` says whether the project has the
159
+ * security-reviewer agent.
160
+ */
161
+ export function securityReview(root, { base, branch, config }) {
162
+ const range = `${base}...${branch}`;
163
+ const files = git(root, 'diff', '--name-only', '--no-renames', range).split('\n').filter(Boolean);
164
+ const added = addedLines(git(root, 'diff', '-U0', '--no-renames', '--no-color', range));
165
+ const story = storyOfBranch(branch);
166
+ const { specs, plan } = config.docs ?? {};
167
+ const cites =
168
+ (plan ? readStories(root, plan) : []).find((s) => s.id === story)?.requirements ?? [];
169
+ const texts = specs
170
+ ? markdownFiles(root, specs).map((f) => readFileSync(join(root, f), 'utf8'))
171
+ : [];
172
+ const reasons = securityReasons({ files, added, cites, security: securityRequirementIds(texts) });
173
+ return {
174
+ branch,
175
+ story,
176
+ needed: reasons.length > 0,
177
+ reasons,
178
+ agent: existsSync(join(root, SECURITY_AGENT_FILE)),
179
+ };
180
+ }
@@ -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,
@@ -16,6 +16,7 @@ export const DECISION_KINDS = new Set([
16
16
  'teamCollision',
17
17
  'teamIntent',
18
18
  'teamReview',
19
+ 'teamDrift',
19
20
  ]);
20
21
 
21
22
  /** What each answer does, in the person's words. */
@@ -72,6 +73,8 @@ function whyOf(line, check) {
72
73
  if (line.kind === 'teamIntent')
73
74
  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
75
  if (line.kind === 'teamReview') return "It's in a lane you own, so its review is yours.";
76
+ if (line.kind === 'teamDrift')
77
+ 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
78
  return null;
76
79
  }
77
80
 
@@ -3,7 +3,16 @@
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', 'rules', 'design', 'setup']);
6
+ const LEAD_STEPS = new Set([
7
+ 'dispatch',
8
+ 'review',
9
+ 'lead',
10
+ 'merge',
11
+ 'rules',
12
+ 'design',
13
+ 'setup',
14
+ 'update',
15
+ ]);
7
16
 
8
17
  /**
9
18
  * The prompt for a step from `code-kit next --json` (LOOP-1), or null when it isn't the lead's to
@@ -27,6 +36,9 @@ export function leadPrompt(step, { cli = 'code-kit' } = {}) {
27
36
  return ids.length
28
37
  ? `Before any code is written, set up ${ids[0].split(':')[0]}: run /${ids[0]}. ${step.why ?? ''}`.trim()
29
38
  : null;
39
+ // ONB-6: an older setup brought up to date before the next story; init shows the person the diff.
40
+ if (step.step === 'update')
41
+ return `Before the next story, bring code-kit's setup up to date: run /code-kit:init. ${step.why ?? ''}`.trim();
30
42
  // ONB-2: the screens designed, and approved by the person, before they're built.
31
43
  if (step.step === 'design')
32
44
  return ids.length
@@ -61,6 +73,7 @@ export function stepLabel(step) {
61
73
  rules: 'Rules onto',
62
74
  design: 'Design',
63
75
  setup: 'Set up',
76
+ update: 'Update the setup',
64
77
  }[step.step] ?? step.step;
65
78
  return ids ? `${verb} ${ids}` : verb;
66
79
  }
@@ -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;