@warren-dean/code-kit 0.8.1 → 0.9.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.
package/bin/code-kit.mjs CHANGED
@@ -53,11 +53,20 @@ import { traceFile } from '../hooks/lib/trace.mjs';
53
53
  import { buildStatus, specCheckRows } from '../hooks/lib/plan.mjs';
54
54
  import { recordSentBack } from '../hooks/lib/reviews.mjs';
55
55
  import { storyTimes } from '../hooks/lib/timeline.mjs';
56
- import { storyMap, trail } from '../hooks/lib/storymap.mjs';
56
+ import { PROJECT, projectMap, storyMap, trail } from '../hooks/lib/storymap.mjs';
57
57
  import { journal } from '../hooks/lib/journal.mjs';
58
58
  import { dequeue, enqueue, lastMerged, readQueue, settle, waiting } from '../hooks/lib/queue.mjs';
59
59
  import { SETTINGS, parseSetting, settingOf, withSetting } from '../hooks/lib/harness.mjs';
60
60
  import { readLoop, writeLoop } from '../hooks/lib/loop-state.mjs';
61
+ import {
62
+ claimChanges,
63
+ storyOfBranch,
64
+ syncBoard,
65
+ teamOf,
66
+ teamProvider,
67
+ whoami,
68
+ } from '../hooks/lib/team.mjs';
69
+ import { codeowners, decisionRecord, slugOf } from '../hooks/lib/teamfacts.mjs';
61
70
  import {
62
71
  APPROVAL_MINUTES,
63
72
  approvalLifetime,
@@ -101,7 +110,9 @@ const via = option('--via');
101
110
  const sessionId = option('--session');
102
111
  const minutesGiven = option('--minutes');
103
112
  const answerGiven = option('--answer');
104
- const FLAGS = ['--write', '--no-checks', '--json', '--delegated', '--person'];
113
+ const leaseGiven = option('--lease');
114
+ const touchesGiven = option('--touches');
115
+ const FLAGS = ['--write', '--no-checks', '--json', '--delegated', '--person', '--fresh', '--peek'];
105
116
  const flag = (name) => args.includes(name);
106
117
  const writeBaseline = flag('--write');
107
118
  const [command, ...rest] = args.filter((a) => !FLAGS.includes(a));
@@ -148,6 +159,9 @@ function checkJson() {
148
159
  report.harnessSet = Object.keys(SETTINGS).filter((k) => settingOf(raw.harness, k) !== undefined);
149
160
  // Delegation is the base's, by the rules committed there: a branch can't grant itself merges.
150
161
  const delegation = mergeDelegation('.', config, file);
162
+ // TEAM-1: the team, with its defaults, and who the developer at this machine is.
163
+ report.team = teamOf(raw);
164
+ report.me = report.team ? whoami(resolve('.')) : null;
151
165
  report.delegatesMerge = delegation.delegated;
152
166
  report.firstMerge = delegation.first;
153
167
  out(JSON.stringify(report, null, 2));
@@ -164,6 +178,20 @@ function check() {
164
178
  for (const c of raw.checks ?? []) out(`Check ${c.name}: ${c.run.join(' && ')}`);
165
179
  if (!raw.docs?.specs) out('No docs.specs: lanes are not held to a spec-check report.');
166
180
  for (const name of load().config.adapters) out(`Adapter ${name}: active (see \`adapters\`)`);
181
+ const team = teamOf(raw);
182
+ if (team) {
183
+ const me = whoami(resolve('.'));
184
+ out(
185
+ `Team: ${team.techLead} is the tech lead; a claim is stale after ${team.staleDays} working day${team.staleDays === 1 ? '' : 's'} without a push; provider ${team.provider === 'github' ? 'GitHub' : team.provider.command}.`,
186
+ );
187
+ for (const [lane, owners] of Object.entries(team.owners))
188
+ out(`Lane ${lane} is owned by ${owners.join(', ')}`);
189
+ out(
190
+ me
191
+ ? `You are ${me}${me === team.techLead ? ', the tech lead' : ''}.`
192
+ : "You aren't signed in to GitHub (gh auth login), so claims and owners can't be checked.",
193
+ );
194
+ }
167
195
  }
168
196
 
169
197
  function who(paths) {
@@ -547,6 +575,10 @@ function mergeOne(branch, config, byPerson) {
547
575
  'conflict',
548
576
  );
549
577
  }
578
+ // TEAM-1: a merged story's claim ends, whoever merged it.
579
+ const team = teamOf(config);
580
+ const story = storyOfBranch(branch);
581
+ if (team && story) teamProvider(resolve('.'), team).release(story, { merged: true });
550
582
  // A lane agent's worktree has done its work once its branch is merged: removed when it holds
551
583
  // nothing uncommitted (the branch stays), so finished lanes don't pile up checkouts.
552
584
  let removed = '';
@@ -651,6 +683,18 @@ function queue() {
651
683
  */
652
684
  function mapCommand(id) {
653
685
  const { config, raw } = load();
686
+ // MAP-5: the whole project, by folder, for when no story is at work.
687
+ if (id === PROJECT) {
688
+ const map = projectMap(resolve('.'), config, tracked());
689
+ if (flag('--json')) return out(JSON.stringify(map, null, 2));
690
+ out(`The whole project (${map.files.length} folder(s)):`);
691
+ for (const layer of map.layers) {
692
+ out(` ${layer}`);
693
+ for (const f of map.files.filter((x) => x.layer === layer))
694
+ out(` ${f.path} (${f.files})${f.imports.length ? ` → ${f.imports.join(', ')}` : ''}`);
695
+ }
696
+ return;
697
+ }
654
698
  const { specs, plan } = config.docs ?? {};
655
699
  if (!specs || !plan)
656
700
  die('map needs docs.specs and docs.plan in the config. The spec-design skill writes both.');
@@ -1209,6 +1253,230 @@ async function approve(given) {
1209
1253
  );
1210
1254
  }
1211
1255
 
1256
+ /** How long ago an ISO time was, in words: "3 hours ago". */
1257
+ function ago(iso) {
1258
+ const minutes = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 60000));
1259
+ if (minutes < 1) return 'just now';
1260
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
1261
+ const hours = Math.round(minutes / 60);
1262
+ if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
1263
+ return `${Math.round(hours / 24)} days ago`;
1264
+ }
1265
+
1266
+ /**
1267
+ * The team (TEAM-2, TEAM-3, TEAM-8): `team` says who's on it and what's claimed, intended and open;
1268
+ * `team claim <story> [--lease globs]` and `team release <story>` change a claim through the provider.
1269
+ */
1270
+ function teamCommand() {
1271
+ const { raw } = load();
1272
+ const team = teamOf(raw);
1273
+ if (!team)
1274
+ die(
1275
+ 'This project has no "team" in its config, so it has no claims or owners. Add "team": { "techLead": "<GitHub handle>" } to share them.',
1276
+ );
1277
+ const provider = teamProvider(resolve('.'), team);
1278
+ const [sub, story] = rest;
1279
+ const holderWords = (h) =>
1280
+ `${h.story} is claimed by ${h.by} (${ago(h.at)}). Ask them, or the tech lead (${team.techLead}), to release it.`;
1281
+ if (sub === 'claim') {
1282
+ if (!story) die('code-kit team claim <story> [--lease "<glob>,<glob>"]');
1283
+ const lease = (leaseGiven ?? '')
1284
+ .split(',')
1285
+ .map((g) => g.trim())
1286
+ .filter(Boolean);
1287
+ const r = provider.claim(story, lease);
1288
+ if (r.holder) die(holderWords(r.holder));
1289
+ if (!r.ok) die(r.why);
1290
+ return out(
1291
+ `Claimed ${story} as ${r.claim.by}${lease.length ? `, leasing ${lease.join(', ')}` : ''}.`,
1292
+ );
1293
+ }
1294
+ // TEAM-12: the plan mirrored onto the team's GitHub Project.
1295
+ if (sub === 'board') {
1296
+ if (!team.board)
1297
+ die(
1298
+ 'This team has no board. Add "board": { "owner": "<user or organisation>", "number": <the Project\'s number> } to "team" in the config.',
1299
+ );
1300
+ const { config, raw: rawConfig } = load();
1301
+ if (!rawConfig.docs?.plan)
1302
+ die('The board mirrors the plan, and this project has no docs.plan.');
1303
+ const status = buildStatus('.', config, base(config, false), tracked());
1304
+ const state = provider.state();
1305
+ const r = syncBoard(resolve('.'), team, status.stories, state.claims ?? []);
1306
+ if (!r.ok) die(r.why);
1307
+ return out(
1308
+ `Board ${team.board.owner}/${team.board.number} synced: ${r.created} item${r.created === 1 ? '' : 's'} added, ${r.edited} field${r.edited === 1 ? '' : 's'} changed.`,
1309
+ );
1310
+ }
1311
+ // TEAM-11: GitHub's CODEOWNERS from the config, so GitHub asks the right people to review.
1312
+ if (sub === 'codeowners') {
1313
+ const text = codeowners({
1314
+ techLead: team.techLead,
1315
+ leadPaths: raw.lead.paths,
1316
+ lanes: raw.lanes,
1317
+ });
1318
+ if (!flag('--write')) return out(text.trimEnd());
1319
+ mkdirSync(resolve('.github'), { recursive: true });
1320
+ writeFileSync(resolve('.github/CODEOWNERS'), text);
1321
+ return out(
1322
+ "Wrote .github/CODEOWNERS. Commit it, and require code owners' review in a ruleset on the protected branch, so GitHub holds every pull request to it.",
1323
+ );
1324
+ }
1325
+ // TEAM-9: the holder keeps a stale claim (the clock starts again); the tech lead reassigns one.
1326
+ if (sub === 'keep') {
1327
+ if (!story) die('code-kit team keep <story>');
1328
+ const r = provider.claim(story);
1329
+ if (r.holder) die(holderWords(r.holder));
1330
+ if (!r.ok) die(r.why);
1331
+ return out(
1332
+ `Kept ${story}: it isn't stale until ${team.staleDays} more working day${team.staleDays === 1 ? '' : 's'} without a push.`,
1333
+ );
1334
+ }
1335
+ if (sub === 'reassign') {
1336
+ const to = rest[2];
1337
+ if (!story || !to) die('code-kit team reassign <story> <GitHub handle>');
1338
+ const r = provider.reassign(story, to);
1339
+ if (!r.ok) die(r.why);
1340
+ return out(`Reassigned ${story} from ${r.from} to ${r.to}, with its lease.`);
1341
+ }
1342
+ if (sub === 'release') {
1343
+ if (!story) die('code-kit team release <story>');
1344
+ const r = provider.release(story);
1345
+ if (!r.ok) die(r.why ?? holderWords(r.holder));
1346
+ return out(
1347
+ r.released ? `Released ${story} (${r.claim.by}'s claim).` : `${story} isn't claimed.`,
1348
+ );
1349
+ }
1350
+ // TEAM-6: what everyone did since you last looked; reading it marks it read, unless --peek.
1351
+ if (sub === 'since') {
1352
+ const root = resolve('.');
1353
+ const seenFile = join(root, '.claude/state/team-seen.json');
1354
+ let seen = null;
1355
+ try {
1356
+ seen = JSON.parse(readFileSync(seenFile, 'utf8'));
1357
+ } catch {
1358
+ // never looked: the last day
1359
+ }
1360
+ const from = seen ? Date.parse(seen.at) : Date.now() - 24 * 3600_000;
1361
+ const state = provider.state({ fresh: true });
1362
+ const activity = provider.since(from);
1363
+ const changes =
1364
+ seen && state.reachable !== false ? claimChanges(seen.claims, state.claims) : [];
1365
+ const all = [...(activity ?? []), ...changes].sort((a, b) => a.at - b.at);
1366
+ const people = {};
1367
+ for (const e of all) (people[e.by] ??= []).push(e);
1368
+ if (!flag('--peek')) {
1369
+ mkdirSync(join(root, '.claude/state'), { recursive: true });
1370
+ writeFileSync(
1371
+ seenFile,
1372
+ JSON.stringify({ at: new Date().toISOString(), claims: state.claims ?? [] }) + '\n',
1373
+ );
1374
+ }
1375
+ if (flag('--json'))
1376
+ return out(
1377
+ JSON.stringify(
1378
+ { since: new Date(from).toISOString(), github: activity !== null, people },
1379
+ null,
1380
+ 2,
1381
+ ),
1382
+ );
1383
+ out(
1384
+ `Since ${seen ? ago(seen.at) : 'yesterday'}${activity === null ? " (GitHub's activity couldn't be read; claims only)" : ''}:`,
1385
+ );
1386
+ if (!all.length) return out(' Nothing new.');
1387
+ for (const [who, list] of Object.entries(people))
1388
+ out(` ${who}${who === state.me ? ' (you)' : ''}: ${list.map((e) => e.text).join('; ')}.`);
1389
+ return;
1390
+ }
1391
+ if (sub !== undefined)
1392
+ die(
1393
+ 'code-kit team [--json] [--fresh] | team since [--json] [--peek] | team claim|keep|release <story> | team reassign <story> <handle>',
1394
+ );
1395
+ const state = provider.state({ fresh: flag('--fresh') });
1396
+ if (flag('--json')) return out(JSON.stringify(state, null, 2));
1397
+ const you = state.me
1398
+ ? `You are ${state.me}${state.me === team.techLead ? ', the tech lead' : ''}.`
1399
+ : "You aren't signed in to GitHub (gh auth login).";
1400
+ out('Team mode is a preview: report anything that looks wrong.');
1401
+ out(`Tech lead: ${team.techLead}. ${you}`);
1402
+ if (state.reachable === false)
1403
+ out(
1404
+ `${state.why ?? "The shared repository can't be reached"}: claims and leases aren't checked until it can be; every other rule still holds.`,
1405
+ );
1406
+ if (state.github === false) out("GitHub's issues and pull requests couldn't be read (gh).");
1407
+ for (const [lane, owners] of Object.entries(team.owners))
1408
+ out(`Lane ${lane}: ${owners.join(', ')}`);
1409
+ out(state.claims.length ? 'Claims:' : 'Nothing is claimed.');
1410
+ for (const c of state.claims)
1411
+ out(
1412
+ ` ${c.story} by ${c.by}, ${ago(c.at)}${c.stale ? ` · stale, nothing since ${ago(c.active)}` : ''}${c.lease.length ? ` · leases ${c.lease.join(', ')}` : ''}`,
1413
+ );
1414
+ if (state.intents.length) out('Intents:');
1415
+ for (const i of state.intents)
1416
+ out(
1417
+ ` #${i.id} ${i.about} (${i.by})${i.touches.length ? ` · touches ${i.touches.join(', ')}` : ''}`,
1418
+ );
1419
+ if (state.pulls.length) out('Open pull requests:');
1420
+ for (const p of state.pulls)
1421
+ out(` #${p.number} ${p.title} (${p.by}, ${p.branch}${p.draft ? ', draft' : ''})`);
1422
+ }
1423
+
1424
+ /**
1425
+ * TEAM-5: `intend "<what and why>" [--touches globs]` raises an intent before any code, so the team sees
1426
+ * a change of direction first; `intend agree|drop|duplicate <id> [--reason "…"]` is the tech lead's answer.
1427
+ */
1428
+ function intendCommand() {
1429
+ const { raw } = load();
1430
+ const team = teamOf(raw);
1431
+ if (!team) die('This project has no "team" in its config, so intents have nowhere to go.');
1432
+ const provider = teamProvider(resolve('.'), team);
1433
+ const [first, id] = rest;
1434
+ // TEAM-10: an agreed intent, written where agents read it.
1435
+ if (first === 'record') {
1436
+ if (!/^\d+$/.test(id ?? '')) die('code-kit intend record <discussion number>');
1437
+ const d = provider.intent(Number(id));
1438
+ if (!d) die("GitHub can't be reached, so the intent can't be read.");
1439
+ if (d.missing) die(`There's no intent #${id} in ${team.intents}.`);
1440
+ if (!(d.closed && d.stateReason === 'RESOLVED'))
1441
+ die(`Intent #${id} isn't agreed yet: only an agreed intent becomes a decision record.`);
1442
+ const dir = raw.docs?.decisions ?? 'docs/decisions';
1443
+ mkdirSync(resolve(dir), { recursive: true });
1444
+ const existing = readdirSync(resolve(dir)).filter((f) => f.endsWith('.md'));
1445
+ const already = existing.find((f) => read(join(dir, f)).includes(`**Discussion:** ${d.url}`));
1446
+ if (already) return out(`Intent #${id} is already recorded: ${join(dir, already)}`);
1447
+ const last = Math.max(0, ...existing.map((f) => Number(f.match(/^(\d+)-/)?.[1] ?? 0)));
1448
+ const path = join(dir, `${String(last + 1).padStart(4, '0')}-${slugOf(d.title)}.md`);
1449
+ writeFileSync(resolve(path), decisionRecord(d, team.techLead));
1450
+ return out(
1451
+ `Wrote ${path}. Commit it on a branch (lead/decision-${id}), push it and open a pull request, so it's reviewed and merged like any change; once merged, every agent reads it.`,
1452
+ );
1453
+ }
1454
+ if (first === 'agree' || first === 'drop' || first === 'duplicate') {
1455
+ if (!/^\d+$/.test(id ?? '')) die(`code-kit intend ${first} <issue number> [--reason "…"]`);
1456
+ const outcome = { agree: 'agreed', drop: 'dropped', duplicate: 'duplicate' }[first];
1457
+ const r = provider.settle(Number(id), outcome, reason ?? '');
1458
+ if (!r.ok) die(r.why);
1459
+ return out(
1460
+ first === 'agree'
1461
+ ? `Agreed intent #${id}: it's how the team goes now. Record it so every agent builds on it: code-kit intend record ${id}`
1462
+ : first === 'drop'
1463
+ ? `Dropped intent #${id}.`
1464
+ : `Marked intent #${id} a duplicate.`,
1465
+ );
1466
+ }
1467
+ const about = rest.join(' ').trim();
1468
+ if (!about) die('code-kit intend "<what you\'ll change, and why>" [--touches "<glob>,<glob>"]');
1469
+ const touches = (touchesGiven ?? '')
1470
+ .split(',')
1471
+ .map((t) => t.trim())
1472
+ .filter(Boolean);
1473
+ const r = provider.intend(about, touches);
1474
+ if (!r.ok) die(r.why);
1475
+ out(
1476
+ `Raised intent #${r.intent.id}: ${about}${touches.length ? ` (touches ${touches.join(', ')})` : ''}. The team sees it now; the tech lead (${team.techLead}) agrees or drops it. ${r.intent.url}`,
1477
+ );
1478
+ }
1479
+
1212
1480
  /**
1213
1481
  * The lead loop's pause (LOOP-6): `loop` says whether the person paused it; `loop pause|resume`, the
1214
1482
  * person's act from the code-kit band, records it, so other tools' background work pauses with it.
@@ -1241,6 +1509,8 @@ const commands = {
1241
1509
  settings,
1242
1510
  hold: () => (rest.length === 1 ? hold(rest[0]) : usage()),
1243
1511
  queue,
1512
+ team: teamCommand,
1513
+ intend: intendCommand,
1244
1514
  story: () => (rest.length === 1 ? story(rest[0]) : usage()),
1245
1515
  map: () => (rest.length === 1 ? mapCommand(rest[0]) : usage()),
1246
1516
  trail: trailCommand,
@@ -1270,8 +1540,12 @@ function usage() {
1270
1540
  ' | hold <id> [--minutes N] | hold <id> --answer approved|refused\n' +
1271
1541
  ' | queue [--json] | queue add|drop <branch> | queue rules | queue merge --delegated|--person\n' +
1272
1542
  ' | loop [--json] | loop pause|resume\n' +
1273
- ' | map <story> [--json] | trail [--json] | journal [--json]\n' +
1274
- ' | story <id> [--json] | timeline [--json]',
1543
+ ' | map <story>|project [--json] | trail [--json] | journal [--json]\n' +
1544
+ ' | story <id> [--json] | timeline [--json]\n' +
1545
+ ' | team [--json] [--fresh] | team since [--json] [--peek]\n' +
1546
+ ' | team claim <story> [--lease globs] | team keep|release <story>\n' +
1547
+ ' | team reassign <story> <handle> | team codeowners [--write] | team board\n' +
1548
+ ' | intend "<what and why>" [--touches globs] | intend agree|drop|duplicate <id> [--reason "…"] | intend record <id>',
1275
1549
  );
1276
1550
  }
1277
1551
  await (commands[command] ?? usage)();
@@ -12,6 +12,8 @@ import { addedPackages, dependencyApproval, isDependencyFile } from './lib/depen
12
12
  import { CODE_KIT, GIT, codeOf, commandsOf, runs } from './lib/shell.mjs';
13
13
  import { block, start } from './lib/hook.mjs';
14
14
  import { CONFIG_FILE } from './lib/config.mjs';
15
+ import { laneOfBranch, storyOfBranch, teamOf, teamProvider, whoami } from './lib/team.mjs';
16
+ import { matchesAny } from './lib/glob.mjs';
15
17
  import { noteBefore } from './lane-audit.mjs';
16
18
  import {
17
19
  APPROVAL_LOG,
@@ -166,6 +168,16 @@ if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+loop\s+(pause|resume)\b`)))
166
168
  block(
167
169
  `Blocked: pausing or resuming the lead loop is the person's own act, from the code-kit band or their terminal. Ask them, and stop.\nCommand: ${cmd}`,
168
170
  );
171
+ // TEAM-9: handing someone's claim to someone else is the tech lead's own decision too.
172
+ if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+team\s+reassign\b`)))
173
+ block(
174
+ `Blocked: reassigning a claim is the tech lead's own decision, from their terminal or the code-kit pane. Tell them who holds it, since when, and who you'd suggest, and stop.\nCommand: ${cmd}`,
175
+ );
176
+ // TEAM-5: agreeing or dropping an intent is the tech lead's own decision, from their terminal or the pane.
177
+ if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+intend\s+(agree|drop|duplicate)\b`)))
178
+ block(
179
+ `Blocked: agreeing or dropping an intent is the tech lead's own decision, from their terminal or the code-kit pane. Tell them what you'd recommend and why, and stop.\nCommand: ${cmd}`,
180
+ );
169
181
  // The harness settings are the person's to change, from the pane or their terminal.
170
182
  if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+settings\s+set\b`)))
171
183
  block(
@@ -266,6 +278,58 @@ if (writesHistory && config.branches.protected.includes(branch) && !settingUp) {
266
278
  );
267
279
  }
268
280
 
281
+ // TEAM-1, TEAM-4: in a team, making a story's branch claims the story (the dispatch), refused when the
282
+ // lane is someone else's or the story is already claimed; a commit on it grows the claim's lease with
283
+ // what the branch changes. When the shared repository can't be reached, nothing here is checked.
284
+ const team = teamOf(config);
285
+ if (team) {
286
+ const me = whoami(project);
287
+ const made = commandsOf(cmd)
288
+ .map((c) =>
289
+ c.text.match(
290
+ new RegExp(
291
+ String.raw`^${GIT}(?:switch\s+(?:-c|--create)|checkout\s+-b|worktree\s+add\b.*?\s-b)\s+(\S+)`,
292
+ ),
293
+ ),
294
+ )
295
+ .find(Boolean)?.[1];
296
+ const story = storyOfBranch(made);
297
+ if (story && me) {
298
+ const lane = laneOfBranch(made);
299
+ const owners = team.owners[lane];
300
+ if (owners && !owners.includes(me))
301
+ block(
302
+ `Blocked: the ${lane} lane is ${owners.join(' and ')}'s, so ${story} is theirs to dispatch. Leave it for them, or ask the tech lead (${team.techLead}) to change the lane's owners.\nCommand: ${cmd}`,
303
+ );
304
+ const claimed = teamProvider(project, team).claim(story);
305
+ if (claimed.holder)
306
+ block(
307
+ `Blocked: ${story} is already claimed by ${claimed.holder.by}, so someone is building it. Pick another story, or ask ${claimed.holder.by} or the tech lead (${team.techLead}) to release it.\nCommand: ${cmd}`,
308
+ );
309
+ }
310
+ // The branch by name, which also works before its first commit.
311
+ const named = spawnSync('git', ['-C', root, 'symbolic-ref', '--short', '-q', 'HEAD'], {
312
+ encoding: 'utf8',
313
+ }).stdout?.trim();
314
+ const mine = storyOfBranch(named || branch);
315
+ if (commits && mine && me) {
316
+ const provider = teamProvider(project, team);
317
+ const claim = provider.state().claims?.find((c) => c.story === mine && c.by === me);
318
+ const base = config.branches.protected[0];
319
+ const since = spawnSync('git', ['-C', root, 'merge-base', 'HEAD', base], { encoding: 'utf8' });
320
+ const changed = (args) =>
321
+ spawnSync('git', ['-C', root, 'diff', '--name-only', ...args], { encoding: 'utf8' })
322
+ .stdout.split('\n')
323
+ .filter(Boolean);
324
+ const files = [
325
+ ...(since.status === 0 ? changed([since.stdout.trim()]) : []),
326
+ ...changed(['--cached']),
327
+ ];
328
+ const grown = files.filter((f) => !matchesAny(f, claim?.lease ?? []));
329
+ if (claim && grown.length) provider.claim(mine, grown);
330
+ }
331
+ }
332
+
269
333
  // Kit files edited through the shell follow the same rule as edits through Write/Edit: a command that
270
334
  // names a kit file and writes, judged one simple command at a time, so a redirect elsewhere in the line
271
335
  // (`npm install > /tmp/log; ls .claude/skills`) isn't taken for a write to the kit. A write the shell
@@ -8,6 +8,8 @@ import { loadBaseline, newProblems } from './lib/baseline.mjs';
8
8
  import { CONFIG_FILE } from './lib/config.mjs';
9
9
  import { layerProblems } from './lib/layers.mjs';
10
10
  import { block, start } from './lib/hook.mjs';
11
+ import { teamOf, teamProvider, teamRefusal, whoami } from './lib/team.mjs';
12
+ import { spawnSync } from 'node:child_process';
11
13
  import {
12
14
  actorFor,
13
15
  projectWorktrees,
@@ -35,6 +37,23 @@ if (error) {
35
37
  const problem = writeProblem(actorFor(input, root, config), rel, root, config);
36
38
  if (problem) block(`Blocked: ${problem}`);
37
39
 
40
+ // TEAM-1, TEAM-4: in a team, the tech lead's paths, other people's lanes, claims and leases.
41
+ const team = teamOf(config);
42
+ if (team) {
43
+ const branch = spawnSync('git', ['-C', root, 'symbolic-ref', '--short', '-q', 'HEAD'], {
44
+ encoding: 'utf8',
45
+ }).stdout?.trim();
46
+ const refusal = teamRefusal({
47
+ actor: actorFor(input, root, config),
48
+ rel,
49
+ branch,
50
+ config,
51
+ me: whoami(project),
52
+ state: teamProvider(project, team).state(),
53
+ });
54
+ if (refusal) block(`Blocked: ${refusal}`);
55
+ }
56
+
38
57
  /** The file's text after this call, when the call carries enough to know it. */
39
58
  function textAfter(before) {
40
59
  const ti = input.tool_input ?? {};
@@ -120,6 +120,11 @@ export default {
120
120
  /** The command that opens Context Graph's pane on a file, given its path. */
121
121
  open: { command: 'graph' },
122
122
  },
123
+ // ONB-3: set up before the coding loop, so every lane's first edit already has the why behind it.
124
+ onboard: {
125
+ skill: 'context-graph:init',
126
+ why: 'Context Graph is installed but has no graph in this project yet. Set it up before any code is written, so every lane works from the why behind each file from its first edit.',
127
+ },
123
128
  setup: [
124
129
  '.gitignore: .ctx/archive/, .ctx/bench/, .ctx/models/ and .ctx/serve.json (ctx working files, never committed)',
125
130
  '.gitattributes: .ctx/decisions.ctx merge=union and .ctx/cards.ctx merge=union (lanes record decisions and cards on parallel branches; merging keeps them all)',
@@ -11,6 +11,8 @@
11
11
  // shell.block (`person: true` marks a person's act the lead may do for them under
12
12
  // approvals.lead); restricted: commands only the lead and the listed lanes may run
13
13
  // setup lines for init to apply or tell the person: ignore files, merge drivers, instructions
14
+ // onboard { skill, why }: the tool's own setup skill, which `next` runs before any coding when the
15
+ // config wants the tool ("adapters": { "<name>": true }) but it isn't set up here yet
14
16
  // facts what the tool knows of the lanes' work, for the mod's combined view: { available(root),
15
17
  // lanes(root, { session }) → { [agent type or 'lead']: { withoutUnderstanding, cardsOwed } },
16
18
  // files(root, paths) → { [path]: { rules } }, open: { command } (its view of a file) }
@@ -40,6 +42,16 @@ export function adapterFacts(raw, root, { session = null, paths = null } = {}) {
40
42
  }));
41
43
  }
42
44
 
45
+ /**
46
+ * ONB-3: tools the config wants (`"adapters": { "<name>": true }`) that aren't set up in `root` yet,
47
+ * with each one's setup skill: [{ name, skill, why }].
48
+ */
49
+ export function pendingSetup(raw, root) {
50
+ return ADAPTERS.filter(
51
+ (a) => raw?.adapters?.[a.name] === true && a.onboard && !a.detect(root),
52
+ ).map((a) => ({ name: a.name, ...a.onboard }));
53
+ }
54
+
43
55
  /** The effective config with every active adapter's contributions merged in. */
44
56
  export function applyAdapters(config, raw, root) {
45
57
  const active = activeAdapters(raw, root);
@@ -3,6 +3,7 @@
3
3
  import { execFileSync } from 'node:child_process';
4
4
  import { existsSync, readFileSync } from 'node:fs';
5
5
  import { harnessProblems, harnessSettings } from './harness.mjs';
6
+ import { teamProblems } from './team.mjs';
6
7
  import { join } from 'node:path';
7
8
  import { ADAPTERS, applyAdapters } from './adapters/index.mjs';
8
9
 
@@ -140,6 +141,13 @@ const isRegExp = (v) => {
140
141
  }
141
142
  };
142
143
 
144
+ /**
145
+ * ONB-5: the version of init's setup. 2 adds project skills with the lane agents, the design gate for
146
+ * lanes with screens, a QA lane for end-to-end tests, and companions set up before coding (spec 17). A
147
+ * project set up before (no `setup`, or a lower one) is told what re-running init adds.
148
+ */
149
+ export const SETUP_VERSION = 2;
150
+
143
151
  /** Every problem with a parsed config, as readable sentences; empty when it is valid. */
144
152
  export function validate(c) {
145
153
  if (!isObj(c)) return ['the config must be a JSON object'];
@@ -156,9 +164,14 @@ export function validate(c) {
156
164
  );
157
165
  };
158
166
  need(c.version === 1, '"version" must be 1');
167
+ need(
168
+ c.setup === undefined ||
169
+ (Number.isInteger(c.setup) && c.setup >= 1 && c.setup <= SETUP_VERSION),
170
+ `"setup" must be a whole number from 1 to ${SETUP_VERSION}: the version of init's setup the project has`,
171
+ );
159
172
  // A brownfield project may have no specs yet: then there is no spec-check gate.
160
173
  need(c.docs === undefined || isObj(c.docs), '"docs" must be an object');
161
- for (const k of ['specs', 'principles', 'plan'])
174
+ for (const k of ['specs', 'principles', 'plan', 'decisions'])
162
175
  need(c.docs?.[k] === undefined || isStr(c.docs[k]), `"docs.${k}" must be a path`);
163
176
  need(isObj(c.lead) && isList(c.lead?.paths), '"lead.paths" must be a list of globs');
164
177
  need(
@@ -232,6 +245,7 @@ export function validate(c) {
232
245
  '"branches.protected" must be a list of branch names',
233
246
  );
234
247
  p.push(...harnessProblems(c.harness));
248
+ p.push(...teamProblems(c));
235
249
  return p;
236
250
  }
237
251
 
@@ -5,7 +5,9 @@ import { execFileSync } from 'node:child_process';
5
5
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
6
6
  import { join, posix } from 'node:path';
7
7
  import { loadConfig, mergeDelegation } from './config.mjs';
8
- import { buildStatus, readStories, LEAD } from './plan.mjs';
8
+ import { buildStatus, readStories, DESIGN, LEAD } from './plan.mjs';
9
+ import { pendingSetup } from './adapters/index.mjs';
10
+ import { CONFIG_FILE, SETUP_VERSION } from './config.mjs';
9
11
  import { readQueue, waiting } from './queue.mjs';
10
12
 
11
13
  export const DRAFT_FILE = '.claude/code-kit.draft.json';
@@ -138,6 +140,23 @@ export function nextStep(root, base) {
138
140
  `${plan} has no stories in the \`### ST-n\` form yet; spec-design writes the plan.`,
139
141
  );
140
142
 
143
+ // ONB-3: a companion the config wants is set up before any coding starts.
144
+ let raw = null;
145
+ try {
146
+ raw = JSON.parse(readFileSync(join(root, CONFIG_FILE), 'utf8'));
147
+ } catch {
148
+ // read and validated above; nothing to add
149
+ }
150
+ const [setup] = pendingSetup(raw, root);
151
+ 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
+ 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.",
156
+ );
157
+ // ONB-1: a lane agent naming a skill that doesn't exist works without the recipe it was given.
158
+ for (const missing of missingSkills(root)) attention.push(missing);
159
+
141
160
  const from = base(config);
142
161
  if (!from)
143
162
  attention.push('The base branch has no commits yet, so story branches are read against HEAD.');
@@ -163,13 +182,17 @@ export function nextStep(root, base) {
163
182
  // A ready story with a gap (unknown lane, a requirement no spec defines) waits for the plan's fix.
164
183
  const defined = new Set(status.requirements.map((r) => r.id));
165
184
  const buildable = (s) =>
166
- (Boolean(config.lanes[s.lane]) || s.lane === LEAD) &&
185
+ (Boolean(config.lanes[s.lane]) || s.lane === LEAD || s.lane === DESIGN) &&
167
186
  (s.requirements.length > 0 || s.noRequirements) &&
168
187
  s.requirements.every((r) => defined.has(r));
169
188
  const readyStories = status.stories.filter((s) => s.state === 'ready');
170
189
  // The lead's own stories are built by the lead, not dispatched to a lane agent.
171
190
  const forLead = readyStories.filter((s) => buildable(s) && s.lane === LEAD).map((s) => s.id);
172
- const ready = readyStories.filter((s) => buildable(s) && s.lane !== LEAD).map((s) => s.id);
191
+ const ready = readyStories
192
+ .filter((s) => buildable(s) && s.lane !== LEAD && s.lane !== DESIGN)
193
+ .map((s) => s.id);
194
+ // ONB-2: design stories, designed with the person before any screen that depends on them is built.
195
+ const forDesign = readyStories.filter((s) => buildable(s) && s.lane === DESIGN).map((s) => s.id);
173
196
  const unbuildable = readyStories.filter((s) => !buildable(s)).map((s) => s.id);
174
197
  if (unbuildable.length)
175
198
  attention.push(
@@ -201,6 +224,7 @@ export function nextStep(root, base) {
201
224
  const then = [];
202
225
  // A sent-back story goes back to its lane with the review's findings, before new work starts.
203
226
  const toDispatch = [...fixes, ...ready];
227
+ if (review.length && forDesign.length) then.push({ step: 'design', args: forDesign.join(' ') });
204
228
  if (review.length && toDispatch.length)
205
229
  then.push({ step: 'dispatch', args: toDispatch.join(' ') });
206
230
  if (forLead.length) then.push({ step: 'lead', args: forLead.join(' ') });
@@ -209,6 +233,18 @@ export function nextStep(root, base) {
209
233
  args: review.join(' '),
210
234
  then,
211
235
  });
236
+ if (forDesign.length)
237
+ return result(
238
+ 'design',
239
+ `${forDesign.join(', ')} ${forDesign.length === 1 ? 'designs' : 'design'} the screens before any code is written for them; the person approves each design.`,
240
+ {
241
+ args: forDesign.join(' '),
242
+ then: [
243
+ ...(toDispatch.length ? [{ step: 'dispatch', args: toDispatch.join(' ') }] : []),
244
+ ...(forLead.length ? [{ step: 'lead', args: forLead.join(' ') }] : []),
245
+ ],
246
+ },
247
+ );
212
248
  if (fixes.length)
213
249
  return result(
214
250
  'dispatch',
@@ -257,3 +293,39 @@ export function nextStep(root, base) {
257
293
  `All ${status.stories.length} stories are done; spec-design plans the next milestone or feature.`,
258
294
  );
259
295
  }
296
+
297
+ /** The code-kit skills every agent may name without a project copy. */
298
+ const KIT_SKILLS = (() => {
299
+ try {
300
+ return new Set(readdirSync(new URL('../../skills/', import.meta.url)));
301
+ } catch {
302
+ return new Set();
303
+ }
304
+ })();
305
+
306
+ /**
307
+ * ONB-1: lane agents naming skills under **Skills to use** that don't exist, in words: ["web-engineer
308
+ * names a skill that doesn't exist: new-screen"]. Plugin skills (`plugin:skill`) and code-kit's own are
309
+ * left alone.
310
+ */
311
+ export function missingSkills(root) {
312
+ const dir = join(root, '.claude/agents');
313
+ if (!existsSync(dir)) return [];
314
+ const out = [];
315
+ for (const file of readdirSync(dir).filter((f) => f.endsWith('.md'))) {
316
+ const line = readFileSync(join(dir, file), 'utf8').match(/^\*\*Skills to use:\*\*(.*)$/m)?.[1];
317
+ if (!line) continue;
318
+ const missing = line
319
+ .split(',')
320
+ .map((n) => n.replace(/[`*]/g, '').trim())
321
+ .filter((n) => /^[a-z0-9][a-z0-9-]*$/.test(n))
322
+ .filter(
323
+ (n) => !KIT_SKILLS.has(n) && !existsSync(join(root, '.claude/skills', n, 'SKILL.md')),
324
+ );
325
+ if (missing.length)
326
+ out.push(
327
+ `${file.replace(/\.md$/, '')} names ${missing.length === 1 ? "a skill that doesn't" : "skills that don't"} exist: ${missing.join(', ')}. Write ${missing.length === 1 ? 'it' : 'them'} from the architecture's recipes (re-run init), or take ${missing.length === 1 ? 'it' : 'them'} off the agent.`,
328
+ );
329
+ }
330
+ return out;
331
+ }