@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +10 -0
- package/README.md +4 -1
- package/bin/code-kit.mjs +145 -4
- package/hooks/hooks.json +22 -0
- package/hooks/lib/adapters/hub/client.mjs +708 -0
- package/hooks/lib/adapters/hub/index.mjs +25 -0
- package/hooks/lib/adapters/index.mjs +35 -0
- package/hooks/lib/plan.mjs +24 -0
- package/hooks/lib/snapshot.mjs +352 -0
- package/hooks/lib/team.mjs +46 -7
- package/hooks/lib/teamfacts.mjs +10 -6
- package/hooks/mod/register.mjs +35 -0
- package/hooks/mod/register.test.ts +45 -4
- package/hooks/mod/teamview.mjs +8 -2
- package/hooks/mod/view.mjs +36 -0
- package/hooks/team-session.mjs +57 -0
- package/package.json +1 -1
- package/skills/team-init/SKILL.md +3 -3
package/hooks/mod/register.mjs
CHANGED
|
@@ -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: '
|
|
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
|
|
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: "
|
|
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 \(
|
|
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) => ({
|
package/hooks/mod/teamview.mjs
CHANGED
|
@@ -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
|
-
|
|
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}`]] : []),
|
package/hooks/mod/view.mjs
CHANGED
|
@@ -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.
|
|
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),
|
|
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
|
|
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
|
|