@warren-dean/code-kit 0.19.0 → 0.20.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.
@@ -36,6 +36,8 @@ import {
36
36
  resultPane,
37
37
  settingsView,
38
38
  usageSummary,
39
+ usageForReport,
40
+ presenceOf,
39
41
  withUsage,
40
42
  } from './view.mjs';
41
43
  import { holdable } from './hold.mjs';
@@ -109,6 +111,7 @@ let pendingSetting = null; // a change waiting for the person's reason: { key, v
109
111
  let ledger = {}; // the session's tokens by agent and story (USE-1)
110
112
  let rateLimits = []; // the session's limits, as session.measure last gave them
111
113
  const agentsSeen = new Map(); // agent id → its $.agent.list() entry, kept once it leaves the list
114
+ const present = new Map(); // agent id (or 'main') → its presence for a hosted team provider (HUBR-6)
112
115
  const held = new Map(); // tool_use_id → a call held for the person (HOLD-2)
113
116
  const denied = new Map(); // tool_use_id → a call Claude Code's own permission check refused (PERM-1)
114
117
  const briefLater = new Set(); // the brief's questions the person put off this session (ASK-4)
@@ -1891,6 +1894,26 @@ export function register(on) {
1891
1894
  $.clock.every(1000, () => {
1892
1895
  if (held.size) $.ui.invalidate('ui.render');
1893
1896
  });
1897
+ // HUBR-3, HUBR-6: with a hosted team provider, the session's agents and its token ledger go to it
1898
+ // every 30 seconds, and the project reports what's new. Nothing runs for a team on GitHub.
1899
+ let pulsing = false;
1900
+ $.clock.every(30_000, async () => {
1901
+ if (pulsing || !model.check?.team?.provider?.adapter) return;
1902
+ pulsing = true;
1903
+ try {
1904
+ const now = await $.clock.now();
1905
+ for (const [k, a] of present) if (now - a.at > 10 * 60_000) present.delete(k);
1906
+ const usage = usageForReport(ledger, (key) => agentsSeen.get(key)?.type ?? null);
1907
+ await $.process.run(['node', cli, 'team', 'pulse', '--session', session], {
1908
+ cwd,
1909
+ stdin: JSON.stringify({ agents: Object.fromEntries(present), usage }),
1910
+ });
1911
+ } catch {
1912
+ // tried again in 30 seconds
1913
+ } finally {
1914
+ pulsing = false;
1915
+ }
1916
+ });
1894
1917
  return next(e);
1895
1918
  });
1896
1919
 
@@ -1957,6 +1980,18 @@ export function register(on) {
1957
1980
  busy.tool = toolLine(e);
1958
1981
  busy.at = calledAt;
1959
1982
  } else if (!e.agentId) leadLast = { tool: toolLine(e), at: calledAt };
1983
+ if (model.check?.team?.provider?.adapter)
1984
+ present.set(
1985
+ e.agentId ?? 'main',
1986
+ presenceOf({
1987
+ agentId: e.agentId,
1988
+ type: agentsSeen.get(e.agentId)?.type,
1989
+ tool: e.tool,
1990
+ lane: busy?.lane,
1991
+ lanes: model.state?.lanes,
1992
+ at: calledAt,
1993
+ }),
1994
+ );
1960
1995
  // MAP-2: the file the call reads or edits glows in the code map, in its lane's colour. An agent
1961
1996
  // first seen on this call has its lane found by its type.
1962
1997
  const touch = touchOf(e, where?.cwd);
@@ -85,6 +85,7 @@ function project() {
85
85
  closed: [] as string[],
86
86
  prompts: [] as string[],
87
87
  acts: [] as string[][],
88
+ pulses: [] as any[],
88
89
  runs: 0,
89
90
  settings: [
90
91
  {
@@ -236,6 +237,11 @@ function stub(on: any, w: World) {
236
237
  w.acts.push([...argv.slice(2)]);
237
238
  return ran(0, JSON.stringify(w.teamSince));
238
239
  }
240
+ if (sub === 'team' && argv[3] === 'pulse') {
241
+ w.acts.push([...argv.slice(2)]);
242
+ w.pulses.push(JSON.parse(e.init?.stdin ?? '{}'));
243
+ return ran(0, '');
244
+ }
239
245
  if (sub === 'team' && ['keep', 'release', 'board', 'reassign'].includes(argv[3])) {
240
246
  w.acts.push([...argv.slice(2)]);
241
247
  return ran(0, '');
@@ -4430,7 +4436,7 @@ test('a live provider says so in the Team pane, by its own name, and has a Drift
4430
4436
  w.team = {
4431
4437
  ...teamOf('kim'),
4432
4438
  provider: {
4433
- name: 'the code-kit teams hub',
4439
+ name: 'code-kit hub',
4434
4440
  live: true,
4435
4441
  account: 'kim',
4436
4442
  detail: 'claims, leases and intents as they happen',
@@ -4442,7 +4448,7 @@ test('a live provider says so in the Team pane, by its own name, and has a Drift
4442
4448
  await teamCmd($);
4443
4449
  const ui = await pane($, TEAM);
4444
4450
  expect((await ui.find({ key: 'team-provider' }))?.text).toBe(
4445
- 'Through the code-kit teams hub · live · signed in as kim: claims, leases and intents as they happen',
4451
+ 'Through code-kit hub · live · signed in as kim: claims, leases and intents as they happen',
4446
4452
  );
4447
4453
  await press($, 'team-tab-drift', TEAM);
4448
4454
  expect(
@@ -4456,7 +4462,7 @@ test("with a live provider (the hub), the band says when it fell back to GitHub,
4456
4462
  const at = new Date().toISOString();
4457
4463
  w.team = {
4458
4464
  ...teamOf('kim'),
4459
- fallback: "The code-kit teams hub can't be reached.",
4465
+ fallback: "code-kit hub can't be reached.",
4460
4466
  sessions: [
4461
4467
  {
4462
4468
  login: 'kim',
@@ -4506,7 +4512,7 @@ test("with a live provider (the hub), the band says when it fell back to GitHub,
4506
4512
  expect(w.opened).toEqual([TEAM]);
4507
4513
  const ui = await pane($, TEAM);
4508
4514
  expect((await ui.find({ key: 'team-provider' }))?.text).toMatch(
4509
- /can't be reached \(The code-kit teams hub can't be reached\.\): claims are checked through GitHub/,
4515
+ /can't be reached \(code-kit hub can't be reached\.\): claims are checked through GitHub/,
4510
4516
  );
4511
4517
  expect((await ui.find({ key: 'team-drift-web/st-9' }))?.text).toMatch(
4512
4518
  /web\/st-9 · kim[\s\S]*blocker · spec docs\/specs\/02-booking\.md: BOOK-2 now holds prices in cents\./,
@@ -4532,6 +4538,41 @@ test('TEAM-12 with a board, the mod syncs it in the background, at most every 10
4532
4538
  expect(syncs()).toBe(2);
4533
4539
  });
4534
4540
 
4541
+ test('HUBR-6 with a hosted provider, the mod sends the session agents and token use every 30 seconds', async ($, on) => {
4542
+ const w = project();
4543
+ w.team = teamOf('sam');
4544
+ (w.check as any).team = {
4545
+ techLead: 'alex',
4546
+ provider: {
4547
+ command: 'x',
4548
+ fallback: 'github',
4549
+ adapter: 'hub',
4550
+ url: 'https://h.example',
4551
+ name: 'a hosted provider',
4552
+ },
4553
+ };
4554
+ const clock = await start($, on, w);
4555
+ await $.tool.call({ tool: 'Bash', tool_use_id: 'tu-p', command: 'cat secrets.txt' } as any);
4556
+ await clock.advance(30000);
4557
+ const pulses = () => w.acts.filter((a) => a[0] === 'team' && a[1] === 'pulse');
4558
+ expect(pulses().length).toBe(1);
4559
+ expect(pulses()[0]).toContain('--session');
4560
+ expect(w.pulses[0].agents.main).toMatchObject({ type: 'lead', tool: 'Bash' });
4561
+ // By tool name only: never what the call read or ran.
4562
+ expect(JSON.stringify(w.pulses[0])).not.toMatch(/secrets/);
4563
+ expect(typeof w.pulses[0].usage).toBe('object');
4564
+ });
4565
+
4566
+ test('HUBR-6 a team on GitHub sends no pulse', async ($, on) => {
4567
+ const w = project();
4568
+ w.team = teamOf('sam');
4569
+ (w.check as any).team = { techLead: 'alex', provider: 'github' };
4570
+ const clock = await start($, on, w);
4571
+ await $.tool.call({ tool: 'Bash', tool_use_id: 'tu-q', command: 'ls' } as any);
4572
+ await clock.advance(60000);
4573
+ expect(w.acts.some((a) => a[0] === 'team' && a[1] === 'pulse')).toBe(false);
4574
+ });
4575
+
4535
4576
  test('TRACE-2 a project with more requirements than a list holds still draws its trail', async ($, on) => {
4536
4577
  const w = project();
4537
4578
  w.requirements = Array.from({ length: 134 }, (_, i) => ({
@@ -582,12 +582,18 @@ function setupTab(team, config, { Box, Text, Button }, on) {
582
582
  const rows = [
583
583
  ['Tech lead', team.techLead],
584
584
  ['Stale after', `${team.staleDays ?? t.staleDays ?? 3} working days without a push`],
585
- ['Intents', `discussions in "${t.intents ?? 'Intents'}"`],
585
+ // HUBR-7: a hosted provider keeps intents, in Discussions too only when the team mirrors them.
586
+ [
587
+ 'Intents',
588
+ typeof t.provider === 'object' && !t.provider.command && !t.intents
589
+ ? `kept by ${team.provider?.name ?? 'the provider'}`
590
+ : `discussions in "${t.intents ?? 'Intents'}"`,
591
+ ],
586
592
  [
587
593
  'Provider',
588
594
  team.provider?.name ?? (typeof t.provider === 'object' ? t.provider.command : 'GitHub'),
589
595
  ],
590
- ...(typeof t.provider === 'object' && t.provider.fallback
596
+ ...(typeof t.provider === 'object' && (t.provider.fallback || !t.provider.command)
591
597
  ? [['Fallback', 'GitHub, while the provider is down']]
592
598
  : []),
593
599
  ...(t.board ? [['Board', `${t.board.owner}/${t.board.number}`]] : []),
@@ -1676,6 +1676,42 @@ export function withUsage(ledger, who, usage) {
1676
1676
  };
1677
1677
  }
1678
1678
 
1679
+ /**
1680
+ * HUBR-2: the ledger as a report carries it: [{ story, kind, input, output, cache }], each entry's kind
1681
+ * (lead, lane, reviewer, security-reviewer or background) from who made it and `typeOf(key)`, the agent's
1682
+ * type. Tokens only: no prompt, no message.
1683
+ */
1684
+ export function usageForReport(ledger, typeOf = () => null) {
1685
+ return Object.fromEntries(
1686
+ Object.entries(ledger).map(([id, e]) => {
1687
+ const type = e.key === 'lead' ? null : typeOf(e.key);
1688
+ const kind =
1689
+ e.key === 'lead'
1690
+ ? 'lead'
1691
+ : e.lane
1692
+ ? 'lane'
1693
+ : type === 'security-reviewer'
1694
+ ? 'security-reviewer'
1695
+ : /(^|:)reviewer$/.test(type ?? '')
1696
+ ? 'reviewer'
1697
+ : 'background';
1698
+ return [
1699
+ id,
1700
+ { story: e.story ?? null, kind, input: e.input, output: e.output, cache: e.cache },
1701
+ ];
1702
+ }),
1703
+ );
1704
+ }
1705
+
1706
+ /**
1707
+ * HUBR-6: an agent's presence, by tool name and the branch its lane works on (never what it reads or
1708
+ * writes): { type, tool, branch, at }.
1709
+ */
1710
+ export function presenceOf({ agentId, type, tool, lane, lanes, at }) {
1711
+ const branch = lane ? (lanes?.find((l) => l.name === lane)?.branch ?? null) : null;
1712
+ return { type: agentId ? (type ?? null) : 'lead', tool: tool ?? null, branch, at };
1713
+ }
1714
+
1679
1715
  const total = (e) => e.input + e.output + e.cache;
1680
1716
  const median = (xs) => {
1681
1717
  const s = [...xs].sort((a, b) => a - b);
@@ -0,0 +1,57 @@
1
+ // SessionStart and SessionEnd (code-kit spec 21, HUBR-6): when the team's provider is a hosted one, its
2
+ // adapter holds the session's live link (the event stream and the team's state on disk) from start to
3
+ // end, and the session reports the project's facts once as it starts (HUBR-3). Nothing happens in a
4
+ // project whose team works through GitHub, or that has no team.
5
+ import { execFileSync, spawn } from 'node:child_process';
6
+ import { readFileSync } from 'node:fs';
7
+ import { dirname, join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { providerAdapterOf } from './lib/adapters/index.mjs';
10
+
11
+ let input = {};
12
+ try {
13
+ input = JSON.parse(readFileSync(0, 'utf8') || '{}');
14
+ } catch {
15
+ // no input: nothing to start
16
+ }
17
+ const root = process.env.CLAUDE_PROJECT_DIR ?? input.cwd ?? process.cwd();
18
+ const session = String(input.session_id ?? 'session').replace(/[^\w-]/g, '');
19
+ let raw = null;
20
+ try {
21
+ raw = JSON.parse(readFileSync(join(root, '.claude', 'code-kit.json'), 'utf8'));
22
+ } catch {
23
+ // not a code-kit project
24
+ }
25
+ const hosted = providerAdapterOf(raw?.team?.provider);
26
+ if (hosted && !hosted.problem) {
27
+ const { adapter, url } = hosted;
28
+ if (process.argv[2] === 'start') {
29
+ // The link lives as long as the Claude Code process that started this hook.
30
+ let watch = process.ppid;
31
+ try {
32
+ watch =
33
+ Number(
34
+ execFileSync('ps', ['-o', 'ppid=', '-p', String(process.ppid)], {
35
+ encoding: 'utf8',
36
+ }).trim(),
37
+ ) || process.ppid;
38
+ } catch {
39
+ // the hook's own parent, then
40
+ }
41
+ adapter.session.start(root, session, watch, url);
42
+ // The project's facts as the session starts, in the background: the session doesn't wait on it.
43
+ if (raw.team.report !== false)
44
+ spawn(
45
+ process.execPath,
46
+ [
47
+ join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'code-kit.mjs'),
48
+ 'team',
49
+ 'pulse',
50
+ '--session',
51
+ session,
52
+ ],
53
+ { cwd: root, detached: true, stdio: 'ignore' },
54
+ ).unref();
55
+ }
56
+ if (process.argv[2] === 'end') adapter.session.end(root, session);
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warren-dean/code-kit",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Keeps AI coding agents inside your project's rules: lanes, layer rules, protected paths and proof before finish, enforced by Claude Code hooks and in CI",
5
5
  "license": "MIT",
6
6
  "author": "Warren Langeveldt",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: team-init
3
- description: Set up a code-kit project for a team of developers, change its team setup, or make it one developer's again. Asks who the tech lead is, who owns which lanes, when a claim goes stale and where intents go, shows the team section before writing it, checks GitHub is ready (gh signed in, the shared repository, the intents' discussion category), and offers CODEOWNERS, the Project board and a hosted team provider. Use when the person says they work in a team, wants teammates to share the project, wants to change the tech lead or lane owners, wants to stop working as a team, or runs /code-kit:team-init. Not every developer works in a team: never set one up unasked.
3
+ description: Set up a code-kit project for a team of developers, change its team setup, or make it one developer's again. Asks who the tech lead is, who owns which lanes, when a claim goes stale and where intents go, shows the team section before writing it, checks GitHub is ready (gh signed in, the shared repository, the intents' discussion category), offers CODEOWNERS and the Project board, and connects a hosted team provider only when the config already names one. Use when the person says they work in a team, wants teammates to share the project, wants to change the tech lead or lane owners, wants to stop working as a team, or runs /code-kit:team-init. Not every developer works in a team: never set one up unasked.
4
4
  ---
5
5
 
6
6
  # Set up the team
@@ -25,7 +25,7 @@ Ask only what the config can't answer, with the current value as the default on
25
25
  - **Tech lead:** whose GitHub handle owns the plan, specs, contracts and principles, agrees or drops intents, and may release or reassign anyone's claim. Default: `me`.
26
26
  - **Lane owners:** for each lane, whose sessions build its stories (GitHub handles), or nobody, in which case whoever claims a story first builds it. `git log --format='%an' -- <lane paths> | sort | uniq -c` and CODEOWNERS suggest people, but ask for handles: names in commits aren't handles.
27
27
  - **Stale after:** how many working days without a push before a claim is stale (default 3).
28
- - **Intents category:** the GitHub Discussions category intents go in (default `Intents`).
28
+ - **Intents category:** the GitHub Discussions category intents go in (default `Intents`). Skip it when the config's `team.provider` names a hosted provider (`{ "<name>": "<https address>" }`) and `team.intents` isn't set: that provider keeps the intents.
29
29
  - **Back to one developer:** confirm that claims, owners and intents stop applying, and that teammates' sessions lose them too once it's merged.
30
30
 
31
31
  ## 3. Show, then write
@@ -48,7 +48,7 @@ Each is optional; ask, and do only what they want:
48
48
 
49
49
  - **CODEOWNERS** (TEAM-11): `team codeowners --write` writes `.github/CODEOWNERS` from the config, so GitHub asks the tech lead and lane owners to review. Then they require code owners' review in a ruleset on the protected branch.
50
50
  - **A Project board** (TEAM-12): with a GitHub Project's owner and number, add `"board": { "owner": "<user or organisation>", "number": <n> }` to `team` in the config; the mod then mirrors the plan onto it.
51
- - **A hosted team provider:** if a plugin for one is installed (its connect skill is in your skills list, such as `/code-kit-teams:connect`), offer it: it makes claims, leases and intents live, shows who's at work where, and checks each push for drift. Its connect skill sets `team.provider`, with GitHub as the fallback. If none is installed, don't mention one.
51
+ - **A hosted team provider, only when the config already names one:** hosted providers are in preview, so never offer or suggest one. When `team.provider` is `{ "<name>": "<https address>" }` (set by hand), connect this machine: run `team provider status`; if it isn't signed in, run `team provider signin` in the background, give the person the code and the GitHub address it prints, and wait (they enter the code in their browser; never ask for a password or token); then `team provider connect`, which checks the provider can see the repository and adds its MCP server to `.mcp.json`. Commit `.mcp.json` with the config.
52
52
 
53
53
  ## 5. Finish
54
54