@warren-dean/code-kit 0.18.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 +23 -0
- package/README.md +32 -27
- package/bin/code-kit.mjs +224 -5
- package/hooks/guard-bash.mjs +8 -0
- 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/harness.mjs +17 -2
- package/hooks/lib/instructions.mjs +1 -1
- package/hooks/lib/next.mjs +27 -1
- 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/loop.mjs +20 -3
- package/hooks/mod/register.mjs +116 -7
- package/hooks/mod/register.test.ts +118 -6
- 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/dispatch/SKILL.md +2 -0
- package/skills/review/SKILL.md +2 -2
- package/skills/spec-check/SKILL.md +1 -1
- package/skills/team-init/SKILL.md +3 -3
- package/templates/agent.md +3 -1
package/hooks/lib/team.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
4
4
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
6
|
import { matchesAny } from './glob.mjs';
|
|
7
|
+
import { providerAdapterOf } from './adapters/index.mjs';
|
|
7
8
|
import {
|
|
8
9
|
isHandle,
|
|
9
10
|
isStoryId,
|
|
@@ -57,19 +58,24 @@ export function teamProblems(c) {
|
|
|
57
58
|
);
|
|
58
59
|
if (t.intents !== undefined && !(typeof t.intents === 'string' && t.intents.trim()))
|
|
59
60
|
p.push('"team.intents" must name the discussion category intents go in');
|
|
61
|
+
const adapter = providerAdapterOf(t.provider);
|
|
60
62
|
if (
|
|
61
63
|
t.provider !== undefined &&
|
|
62
64
|
t.provider !== 'github' &&
|
|
65
|
+
!(adapter && !adapter.problem) &&
|
|
63
66
|
!(
|
|
64
67
|
isObj(t.provider) &&
|
|
68
|
+
!adapter &&
|
|
65
69
|
typeof t.provider.command === 'string' &&
|
|
66
70
|
t.provider.command.trim() &&
|
|
67
71
|
(t.provider.fallback === undefined || t.provider.fallback === 'github')
|
|
68
72
|
)
|
|
69
73
|
)
|
|
70
74
|
p.push(
|
|
71
|
-
'"team.provider" must be "github" or { "command": "<the provider\'s command>", "fallback"?: "github" }',
|
|
75
|
+
'"team.provider" must be "github" or { "command": "<the provider\'s command>", "fallback"?: "github" }, or a hosted provider in preview: { "<its name>": "<its https address>" }',
|
|
72
76
|
);
|
|
77
|
+
if (t.report !== undefined && typeof t.report !== 'boolean')
|
|
78
|
+
p.push('"team.report" must be true or false');
|
|
73
79
|
}
|
|
74
80
|
for (const [name, lane] of Object.entries(isObj(c.lanes) ? c.lanes : {})) {
|
|
75
81
|
if (!isObj(lane) || lane.owners === undefined) continue;
|
|
@@ -86,12 +92,26 @@ export function teamProblems(c) {
|
|
|
86
92
|
*/
|
|
87
93
|
export function teamOf(raw) {
|
|
88
94
|
if (!isObj(raw?.team)) return null;
|
|
95
|
+
// HUBR-6: a hosted provider's adapter runs as a command, with GitHub as its fallback.
|
|
96
|
+
const adapter = providerAdapterOf(raw.team.provider);
|
|
97
|
+
const hosted = adapter && !adapter.problem ? adapter : null;
|
|
89
98
|
return {
|
|
90
99
|
techLead: raw.team.techLead,
|
|
91
100
|
staleDays: raw.team.staleDays ?? 3,
|
|
92
|
-
|
|
101
|
+
// HUBR-7: a hosted provider keeps intents; Discussions only when the team mirrors them there.
|
|
102
|
+
intents: raw.team.intents ?? (hosted ? null : 'Intents'),
|
|
93
103
|
board: raw.team.board ?? null,
|
|
94
|
-
|
|
104
|
+
// HUBR-4: whether the project reports its facts to a provider that isn't GitHub.
|
|
105
|
+
report: raw.team.report !== false,
|
|
106
|
+
provider: hosted
|
|
107
|
+
? {
|
|
108
|
+
command: hosted.adapter.command(hosted.url),
|
|
109
|
+
fallback: 'github',
|
|
110
|
+
adapter: hosted.adapter.key,
|
|
111
|
+
url: hosted.url,
|
|
112
|
+
name: hosted.adapter.label,
|
|
113
|
+
}
|
|
114
|
+
: (raw.team.provider ?? 'github'),
|
|
95
115
|
owners: Object.fromEntries(
|
|
96
116
|
Object.entries(raw.lanes ?? {})
|
|
97
117
|
.filter(([, l]) => Array.isArray(l?.owners))
|
|
@@ -139,6 +159,9 @@ export function whoami(root, now = Date.now()) {
|
|
|
139
159
|
// the config, called with the operation's name and its input as JSON on stdin, answering JSON.
|
|
140
160
|
|
|
141
161
|
const CLAIMS = 'refs/code-kit/claims';
|
|
162
|
+
/** HUBR-7: what an intent waits on while the hosted provider that keeps intents is down. */
|
|
163
|
+
const KEPT =
|
|
164
|
+
"This team's intents are kept by its hosted provider, which can't be reached: raise or settle it again when it's back, or set team.intents to mirror intents to a GitHub Discussions category.";
|
|
142
165
|
const SEEN = 'refs/code-kit/seen-claims';
|
|
143
166
|
const TEAM_STATE = '.claude/state/team.json';
|
|
144
167
|
const STATE_SECONDS = 60;
|
|
@@ -157,7 +180,7 @@ function claimOf(message) {
|
|
|
157
180
|
|
|
158
181
|
/**
|
|
159
182
|
* 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
|
|
183
|
+
* `fallback: "github"` (a hosted provider, such as code-kit hub) hands each operation it
|
|
161
184
|
* can't reach to the GitHub provider, so claims are still checked while it's down (TEAM-8).
|
|
162
185
|
*/
|
|
163
186
|
export function teamProvider(root, team) {
|
|
@@ -189,6 +212,7 @@ export function teamProvider(root, team) {
|
|
|
189
212
|
'reassign',
|
|
190
213
|
'intent',
|
|
191
214
|
'since',
|
|
215
|
+
'report',
|
|
192
216
|
].map((op) => [op, either(op)]),
|
|
193
217
|
),
|
|
194
218
|
forget: () => (command.forget(), github.forget()),
|
|
@@ -223,8 +247,14 @@ function githubProvider(root, team) {
|
|
|
223
247
|
return null;
|
|
224
248
|
}
|
|
225
249
|
};
|
|
226
|
-
/**
|
|
250
|
+
/**
|
|
251
|
+
* The repository's discussions, the Intents category among its categories: null when unreadable.
|
|
252
|
+
* HUBR-7: a team whose hosted provider keeps its intents, and that mirrors none to Discussions, has
|
|
253
|
+
* none here to read (`kept`).
|
|
254
|
+
*/
|
|
227
255
|
const discussions = () => {
|
|
256
|
+
if (!team.intents)
|
|
257
|
+
return { repoId: null, enabled: false, category: null, intents: [], kept: true };
|
|
228
258
|
const d = graphql(
|
|
229
259
|
`
|
|
230
260
|
query ($owner: String!, $name: String!) {
|
|
@@ -452,13 +482,15 @@ function githubProvider(root, team) {
|
|
|
452
482
|
provider: {
|
|
453
483
|
name: 'GitHub',
|
|
454
484
|
live: false,
|
|
455
|
-
detail:
|
|
485
|
+
detail: team.intents
|
|
486
|
+
? `claims are refs on origin, intents are discussions in "${team.intents}"`
|
|
487
|
+
: 'claims are refs on origin; intents wait for the hosted provider',
|
|
456
488
|
},
|
|
457
489
|
// TEAM-13: what the team still needs set up for its claims and intents to work.
|
|
458
490
|
setup: [
|
|
459
491
|
...(who ? [] : ["gh isn't signed in on this machine: run gh auth login."]),
|
|
460
492
|
...(claims ? [] : ["The shared repository (origin) can't be reached."]),
|
|
461
|
-
...(found && !found.category
|
|
493
|
+
...(found && !found.category && !found.kept
|
|
462
494
|
? [noCategory(found).replace(/, then raise the intent again\.$/, '.')]
|
|
463
495
|
: []),
|
|
464
496
|
],
|
|
@@ -489,6 +521,7 @@ function githubProvider(root, team) {
|
|
|
489
521
|
if (!by) return { ok: false, why: 'Sign in to GitHub first (gh auth login).' };
|
|
490
522
|
const found = discussions();
|
|
491
523
|
if (!found) return unreachable("GitHub can't be reached, so the intent wasn't raised.");
|
|
524
|
+
if (found.kept) return unreachable(KEPT);
|
|
492
525
|
if (!found.category) return { ok: false, why: noCategory(found) };
|
|
493
526
|
const body = `${touches.length ? `Touches: ${touches.join(', ')}\n\n` : ''}Raised with code-kit by @${by} before any code, so the team sees it first. The tech lead agrees, drops or marks it a duplicate here.`;
|
|
494
527
|
const made = graphql(
|
|
@@ -585,6 +618,7 @@ function githubProvider(root, team) {
|
|
|
585
618
|
},
|
|
586
619
|
/** TEAM-10: one intent with its comments, for its decision record; null when unreadable. */
|
|
587
620
|
intent(id) {
|
|
621
|
+
if (!team.intents) return null;
|
|
588
622
|
const d = graphql(
|
|
589
623
|
`
|
|
590
624
|
query ($owner: String!, $name: String!, $number: Int!) {
|
|
@@ -627,6 +661,7 @@ function githubProvider(root, team) {
|
|
|
627
661
|
},
|
|
628
662
|
/** TEAM-5: the tech lead agrees an intent (resolved), drops it (outdated) or marks it a duplicate. */
|
|
629
663
|
settle(id, outcome, why = '') {
|
|
664
|
+
if (!team.intents) return unreachable(KEPT);
|
|
630
665
|
const by = me();
|
|
631
666
|
if (by !== team.techLead)
|
|
632
667
|
return {
|
|
@@ -689,6 +724,8 @@ function githubProvider(root, team) {
|
|
|
689
724
|
provider.forget();
|
|
690
725
|
return { ok: true, id: Number(id), outcome };
|
|
691
726
|
},
|
|
727
|
+
// HUBR-1: the free team mode has no server to report to; nothing is written anywhere.
|
|
728
|
+
report: () => ({ ok: true, sent: false }),
|
|
692
729
|
forget: () => writeCache(root, null),
|
|
693
730
|
};
|
|
694
731
|
return provider;
|
|
@@ -739,6 +776,8 @@ function commandProvider(root, team, command) {
|
|
|
739
776
|
const r = call('since', { at });
|
|
740
777
|
return Array.isArray(r) ? r : null;
|
|
741
778
|
},
|
|
779
|
+
// HUBR-1: the project's facts, on stdin as the operation's input.
|
|
780
|
+
report: (snapshot) => call('report', { snapshot }),
|
|
742
781
|
forget: () => writeCache(root, null),
|
|
743
782
|
};
|
|
744
783
|
}
|
package/hooks/lib/teamfacts.mjs
CHANGED
|
@@ -122,17 +122,21 @@ export function decisionRecord(d, techLead) {
|
|
|
122
122
|
.replace(/^Touches:.*$/m, '')
|
|
123
123
|
.replace(/Raised with code-kit by @\S+ before any code[^\n]*/, '')
|
|
124
124
|
.trim();
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
.
|
|
125
|
+
// Who settled it: the provider says (a lane owner whose paths it touches may agree it with a hosted
|
|
126
|
+
// provider), or the tech lead, who alone settles intents through GitHub.
|
|
127
|
+
const by = d.settledBy ?? d.settled_by ?? techLead;
|
|
128
|
+
const agreed =
|
|
129
|
+
(d.comments ?? [])
|
|
130
|
+
.filter((c) => c.author?.login === by && /^Agreed by @/.test(c.body))
|
|
131
|
+
.at(-1)
|
|
132
|
+
?.body.replace(/^Agreed by @[\w-]+(?:, [^:.]*)?(: |\.)?/, '')
|
|
133
|
+
.trim() || (typeof d.why === 'string' ? d.why.trim() : '');
|
|
130
134
|
return [
|
|
131
135
|
`# ${d.title}`,
|
|
132
136
|
'',
|
|
133
137
|
'**Status:** agreed',
|
|
134
138
|
`**Date:** ${date}`,
|
|
135
|
-
`**Raised by:** @${d.author?.login ?? 'someone'} · **Agreed by:** @${
|
|
139
|
+
`**Raised by:** @${d.author?.login ?? 'someone'} · **Agreed by:** @${by}`,
|
|
136
140
|
`**Discussion:** ${d.url}`,
|
|
137
141
|
'',
|
|
138
142
|
'## What changes, and why',
|
package/hooks/mod/loop.mjs
CHANGED
|
@@ -340,10 +340,27 @@ export function milestonesDone(stories = []) {
|
|
|
340
340
|
return [...by].filter(([, list]) => list.every((s) => s.state === 'done')).map(([m]) => m);
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
-
/** LEAN-3: what the lead's summary keeps when its context is compacted after a milestone. */
|
|
344
|
-
export const compactInstructions = (milestone) =>
|
|
343
|
+
/** LEAN-3, LEAN-4: what the lead's summary keeps when its context is compacted, after a milestone or not. */
|
|
344
|
+
export const compactInstructions = (milestone = null) =>
|
|
345
345
|
[
|
|
346
|
-
|
|
346
|
+
milestone
|
|
347
|
+
? `${milestone} is done; the build goes on with the next milestone.`
|
|
348
|
+
: "The lead's context is compacted between turns; the build goes on.",
|
|
347
349
|
'Keep: the milestone just finished, in a line; every story in flight now, with its lane, its branch and where it stands (building, in review, in the merge queue, waiting and on what); the decisions and questions waiting on the person; what the person decided or asked for in this session that still applies; and the rules and preferences they set.',
|
|
348
350
|
"Drop: the finished stories' details, lane reports and hand-backs, command output, diffs and file contents. Each is in git, the plan or the lanes' report files, to read again when it's needed.",
|
|
349
351
|
].join('\n');
|
|
352
|
+
|
|
353
|
+
/** LEAN-5: the context a model request carried: what it sent, read from the cache, and wrote to it. */
|
|
354
|
+
export const requestContext = (usage) =>
|
|
355
|
+
(usage?.input_tokens ?? 0) +
|
|
356
|
+
(usage?.cache_read_input_tokens ?? 0) +
|
|
357
|
+
(usage?.cache_creation_input_tokens ?? 0);
|
|
358
|
+
|
|
359
|
+
/** LEAN-5: what a lane agent past its context limit is told. */
|
|
360
|
+
export const wrapUpText = (limit) =>
|
|
361
|
+
`Your context is past ${Math.round(limit / 1000)}k tokens, and every step from here rereads all of it. Finish the step you're on, commit your work, write your report, and hand back now. The lead carries the story on with a fresh agent that reads your report.`;
|
|
362
|
+
|
|
363
|
+
/** LEAN-5, LEAN-6: the lead's prompt to carry a story on with a fresh agent once its agent handed back at the limit. */
|
|
364
|
+
export function continuePrompt({ type, story, branch, cli = 'code-kit' }) {
|
|
365
|
+
return `${type} handed ${story} back at its context limit, with its work committed${branch ? ` on ${branch}` : ''}. Carry the story on with a fresh agent: run node "${cli}" worktrees release ${branch ?? '<branch>'} (it keeps the commits and moves the report to the main checkout), then dispatch ${story} again with the same brief, adding "Read your earlier report first: .claude/state/reports/${(branch ?? '<branch>').replace(/[^\w.-]+/g, '_')}.md in the main checkout": /code-kit:dispatch ${story}`;
|
|
366
|
+
}
|
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';
|
|
@@ -61,6 +63,9 @@ import {
|
|
|
61
63
|
COMPACT_FLOOR,
|
|
62
64
|
PARK_MS,
|
|
63
65
|
compactInstructions,
|
|
66
|
+
continuePrompt,
|
|
67
|
+
requestContext,
|
|
68
|
+
wrapUpText,
|
|
64
69
|
milestonesDone,
|
|
65
70
|
parkWatch,
|
|
66
71
|
readOutcome,
|
|
@@ -106,6 +111,7 @@ let pendingSetting = null; // a change waiting for the person's reason: { key, v
|
|
|
106
111
|
let ledger = {}; // the session's tokens by agent and story (USE-1)
|
|
107
112
|
let rateLimits = []; // the session's limits, as session.measure last gave them
|
|
108
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)
|
|
109
115
|
const held = new Map(); // tool_use_id → a call held for the person (HOLD-2)
|
|
110
116
|
const denied = new Map(); // tool_use_id → a call Claude Code's own permission check refused (PERM-1)
|
|
111
117
|
const briefLater = new Set(); // the brief's questions the person put off this session (ASK-4)
|
|
@@ -1049,6 +1055,23 @@ export function register(on) {
|
|
|
1049
1055
|
const who = attribution(agentId, agentsSeen.get(agentId) ?? null, model.check, model.state);
|
|
1050
1056
|
ledger = withUsage(ledger, who, usage);
|
|
1051
1057
|
$.ui.invalidate('ui.render');
|
|
1058
|
+
// LEAN-5: a lane agent past its context limit is told to wrap up; its story goes on fresh.
|
|
1059
|
+
const limit = harness().agents.lanes.contextLimit;
|
|
1060
|
+
const agent = agentId ? agentsSeen.get(agentId) : null;
|
|
1061
|
+
const lane = Object.entries(model.check?.lanes ?? {}).find(
|
|
1062
|
+
([, l]) => l.agent === agent?.type,
|
|
1063
|
+
);
|
|
1064
|
+
if (!limit || !lane || requestContext(usage) < limit) return;
|
|
1065
|
+
const a = activityOf(agentId, await $.clock.now());
|
|
1066
|
+
if (a.overflow) return;
|
|
1067
|
+
a.overflow = true;
|
|
1068
|
+
a.lane = lane[0];
|
|
1069
|
+
a.story = who.story ?? a.story ?? lane[0];
|
|
1070
|
+
await act.record(
|
|
1071
|
+
'wrap up',
|
|
1072
|
+
`${lane[0]} · ${a.story} at ${Math.round(requestContext(usage) / 1000)}k`,
|
|
1073
|
+
);
|
|
1074
|
+
await $.session.send({ to: { agentId }, text: wrapUpText(limit) }).catch(() => {});
|
|
1052
1075
|
},
|
|
1053
1076
|
// --- the lead loop (spec 05) ---------------------------------------------------------------
|
|
1054
1077
|
record: async (kind, target, prompt) => {
|
|
@@ -1177,14 +1200,15 @@ export function register(on) {
|
|
|
1177
1200
|
const done = milestonesDone(model.state?.stories ?? []);
|
|
1178
1201
|
if (loop.milestones === null) {
|
|
1179
1202
|
loop.milestones = new Set(done);
|
|
1180
|
-
return
|
|
1203
|
+
return act.compactAtThreshold();
|
|
1181
1204
|
}
|
|
1182
1205
|
const fresh = done.filter((m) => !loop.milestones.has(m));
|
|
1183
|
-
|
|
1206
|
+
// LEAN-4: past compact.at, the lead's context is compacted between turns, milestone or not.
|
|
1207
|
+
if (!fresh.length) return act.compactAtThreshold();
|
|
1184
1208
|
const mode = harness().compact?.milestone ?? 'ask';
|
|
1185
1209
|
if (mode === 'off' || offering || !where.interactive || (lease.other && !lease.mine)) {
|
|
1186
1210
|
for (const m of fresh) loop.milestones.add(m);
|
|
1187
|
-
return
|
|
1211
|
+
return act.compactAtThreshold();
|
|
1188
1212
|
}
|
|
1189
1213
|
const box = await $.prompt.read().catch(() => null);
|
|
1190
1214
|
if (box?.text?.trim()) return false;
|
|
@@ -1219,6 +1243,25 @@ export function register(on) {
|
|
|
1219
1243
|
});
|
|
1220
1244
|
return true;
|
|
1221
1245
|
},
|
|
1246
|
+
// LEAN-4: the lead's context compacted at once when it's past compact.at; true when it took over.
|
|
1247
|
+
compactAtThreshold: async () => {
|
|
1248
|
+
const at = harness().compact?.at ?? 40;
|
|
1249
|
+
if (!at || offering || !where.interactive || (lease.other && !lease.mine)) return false;
|
|
1250
|
+
const percent = (await $.session.usage().catch(() => null))?.context?.percent ?? 0;
|
|
1251
|
+
if (percent < Math.max(at, COMPACT_FLOOR)) return false;
|
|
1252
|
+
const box = await $.prompt.read().catch(() => null);
|
|
1253
|
+
if (box?.text?.trim()) return false;
|
|
1254
|
+
Promise.resolve()
|
|
1255
|
+
.then(() => $.session.compact({ instructions: compactInstructions() }))
|
|
1256
|
+
.catch((err) => ({ skip: String(err?.message ?? err) }))
|
|
1257
|
+
.then(async (r) => {
|
|
1258
|
+
if (r?.skip) notice = `The lead's context wasn't compacted: ${r.skip}`;
|
|
1259
|
+
await act.record('compact', `at ${Math.round(percent)}%`);
|
|
1260
|
+
$.ui.invalidate('ui.render');
|
|
1261
|
+
await act.step('resume').catch(() => {});
|
|
1262
|
+
});
|
|
1263
|
+
return true;
|
|
1264
|
+
},
|
|
1222
1265
|
// LOOP-11: the lead's last reply, read once before the loop prompts it again. A reply that waits on
|
|
1223
1266
|
// the person, or a turn they stopped, holds the loop until they speak; a step the lead didn't take
|
|
1224
1267
|
// holds that step while it's still next.
|
|
@@ -1258,9 +1301,15 @@ export function register(on) {
|
|
|
1258
1301
|
return;
|
|
1259
1302
|
}
|
|
1260
1303
|
if (!reply.text.trim()) return act.parkUnmoved(sent, null);
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1304
|
+
// LEAN-6: the reply and the prompt it answered are enough to read it, so a small model reads
|
|
1305
|
+
// them, instead of a fork over the lead's whole conversation on its own model at every turn's end.
|
|
1306
|
+
const said = await $.model
|
|
1307
|
+
.complete({
|
|
1308
|
+
model: 'sonnet',
|
|
1309
|
+
prompt: outcomePrompt({ prompt: sent?.prompt ?? null, answer: reply.text }),
|
|
1310
|
+
maxTokens: 600,
|
|
1311
|
+
})
|
|
1312
|
+
.catch(() => ({ isAnswered: false }));
|
|
1264
1313
|
const heard = said.isAnswered ? readOutcome(said.text) : null;
|
|
1265
1314
|
// Spec 05's data: what came of the step, once known.
|
|
1266
1315
|
const record = sent ? loop.steps.findLast((x) => x.prompt === sent.prompt) : null;
|
|
@@ -1845,6 +1894,26 @@ export function register(on) {
|
|
|
1845
1894
|
$.clock.every(1000, () => {
|
|
1846
1895
|
if (held.size) $.ui.invalidate('ui.render');
|
|
1847
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
|
+
});
|
|
1848
1917
|
return next(e);
|
|
1849
1918
|
});
|
|
1850
1919
|
|
|
@@ -1911,6 +1980,18 @@ export function register(on) {
|
|
|
1911
1980
|
busy.tool = toolLine(e);
|
|
1912
1981
|
busy.at = calledAt;
|
|
1913
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
|
+
);
|
|
1914
1995
|
// MAP-2: the file the call reads or edits glows in the code map, in its lane's colour. An agent
|
|
1915
1996
|
// first seen on this call has its lane found by its type.
|
|
1916
1997
|
const touch = touchOf(e, where?.cwd);
|
|
@@ -2021,6 +2102,16 @@ export function register(on) {
|
|
|
2021
2102
|
// SET-4: a lane agent starts on the lane model the person set, unless the lead's call names one or
|
|
2022
2103
|
// the lane's agent file sets its own.
|
|
2023
2104
|
on('agent.spawn', async ($, e, next) => {
|
|
2105
|
+
// LEAN-7: the security reviewer runs on the reviewers' model, unless the call or its file names one.
|
|
2106
|
+
const reviewerModel = harness().agents.reviewer.model;
|
|
2107
|
+
if (e.subagentType === 'security-reviewer' && reviewerModel && !e.model) {
|
|
2108
|
+
const file = where
|
|
2109
|
+
? await $.fs.read(`${where.cwd}/.claude/agents/security-reviewer.md`).catch(() => null)
|
|
2110
|
+
: null;
|
|
2111
|
+
const own = typeof file === 'string' ? agentFileModel(file) : null;
|
|
2112
|
+
if (!own || own === 'inherit') return next({ ...e, model: reviewerModel });
|
|
2113
|
+
return next(e);
|
|
2114
|
+
}
|
|
2024
2115
|
const setting = harness().agents.lanes.model;
|
|
2025
2116
|
const lanes = model.check?.lanes ?? {};
|
|
2026
2117
|
if (!setting || e.model || !Object.values(lanes).some((l) => l.agent === e.subagentType))
|
|
@@ -2078,7 +2169,25 @@ export function register(on) {
|
|
|
2078
2169
|
const res = await next(e);
|
|
2079
2170
|
// LOOP-3: a lane agent that has handed back its work isn't stalled while it waits; a message that
|
|
2080
2171
|
// starts it again shows as its next tool call.
|
|
2081
|
-
if (e.agentId)
|
|
2172
|
+
if (e.agentId) {
|
|
2173
|
+
const a = activityOf(e.agentId, await $.clock.now());
|
|
2174
|
+
a.handedBack = true;
|
|
2175
|
+
// LEAN-5: an agent that handed back at its limit: the lead carries its story on with a fresh one.
|
|
2176
|
+
if (a.overflow && !a.continued && act) {
|
|
2177
|
+
a.continued = true;
|
|
2178
|
+
const agent = agentsSeen.get(e.agentId);
|
|
2179
|
+
const story = (model.state?.stories ?? []).find((s) => s.id === a.story);
|
|
2180
|
+
loop.pending.push({
|
|
2181
|
+
story: a.story,
|
|
2182
|
+
prompt: continuePrompt({
|
|
2183
|
+
type: agent?.type ?? 'The lane agent',
|
|
2184
|
+
story: a.story,
|
|
2185
|
+
branch: story?.branch ?? null,
|
|
2186
|
+
cli: where?.cli,
|
|
2187
|
+
}),
|
|
2188
|
+
});
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2082
2191
|
// REVW-3: a subagent's last turn may be the reviewer's report.
|
|
2083
2192
|
if (e.agentId && act) await act.reviewed(e.agentId, e.answer).catch(() => {});
|
|
2084
2193
|
// LOOP-1: the lead is idle; the loop's next step, if one is due (submitted from turn.complete,
|
|
@@ -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, '');
|
|
@@ -382,6 +388,8 @@ function stub(on: any, w: World) {
|
|
|
382
388
|
});
|
|
383
389
|
on('model.complete', ($: any, e: any) => {
|
|
384
390
|
w.sideAsks.push({ model: e.model, prompt: e.prompt });
|
|
391
|
+
// LEAN-6: the loop reads the lead's reply with a small model; the test's reading answers it.
|
|
392
|
+
if (/Say where that reply left things/.test(e.prompt)) return { value: w.sideReply };
|
|
385
393
|
return { value: { isAnswered: true, text: 'From the facts alone.', usage: null } };
|
|
386
394
|
});
|
|
387
395
|
// Claude Code's own settings hooks beneath: none answer a permission denial.
|
|
@@ -2312,6 +2320,60 @@ test(
|
|
|
2312
2320
|
},
|
|
2313
2321
|
);
|
|
2314
2322
|
|
|
2323
|
+
test('LEAN-5 a lane agent past its context limit is told to wrap up, and its story goes on with a fresh agent', async ($, on) => {
|
|
2324
|
+
const w = project();
|
|
2325
|
+
w.next = { step: 'wait', args: '', then: [] };
|
|
2326
|
+
w.agents = [
|
|
2327
|
+
{ id: 'a1', type: 'web-engineer', description: 'ST-4 booking form', status: 'running' },
|
|
2328
|
+
];
|
|
2329
|
+
const clock = await start($, on, w);
|
|
2330
|
+
await clock.advance(2000);
|
|
2331
|
+
await request($, 'a1', 100);
|
|
2332
|
+
expect(w.sent).toEqual([]);
|
|
2333
|
+
// 270k of context, past the 250k limit: told once, however many requests follow.
|
|
2334
|
+
await request($, 'a1', 300);
|
|
2335
|
+
await request($, 'a1', 320);
|
|
2336
|
+
expect(w.sent).toHaveLength(1);
|
|
2337
|
+
expect(w.sent[0].text).toMatch(/^Your context is past 250k tokens/);
|
|
2338
|
+
await $.turn.complete({
|
|
2339
|
+
turnId: 't-a1',
|
|
2340
|
+
agentId: 'a1',
|
|
2341
|
+
answer: 'ST-4: committed, report written.',
|
|
2342
|
+
durationMs: 1,
|
|
2343
|
+
isAborted: false,
|
|
2344
|
+
} as any);
|
|
2345
|
+
await leadTurn($, 't-2');
|
|
2346
|
+
await settle(clock);
|
|
2347
|
+
expect(w.prompts.at(-1)).toMatch(
|
|
2348
|
+
/^web-engineer handed ST-4 back at its context limit, with its work committed on web\/st-4\. Carry the story on with a fresh agent: run node ".*" worktrees release web\/st-4/,
|
|
2349
|
+
);
|
|
2350
|
+
expect(w.prompts.at(-1)).toMatch(
|
|
2351
|
+
/Read your earlier report first: \.claude\/state\/reports\/web_st-4\.md/,
|
|
2352
|
+
);
|
|
2353
|
+
});
|
|
2354
|
+
|
|
2355
|
+
test("LEAN-4 past compact.at the lead's context is compacted between turns, without asking", async ($, on) => {
|
|
2356
|
+
const w = project();
|
|
2357
|
+
const compacted: string[] = [];
|
|
2358
|
+
on('session.compact', ($: any, e: any) => {
|
|
2359
|
+
compacted.push(e.instructions);
|
|
2360
|
+
return {
|
|
2361
|
+
messages: [{ role: 'user', text: 'Summary: ST-4 in progress on web/st-4.', toolUses: [] }],
|
|
2362
|
+
};
|
|
2363
|
+
});
|
|
2364
|
+
w.contextPercent = 35;
|
|
2365
|
+
const clock = await start($, on, w);
|
|
2366
|
+
await leadTurn($);
|
|
2367
|
+
await settle(clock);
|
|
2368
|
+
expect(compacted).toEqual([]);
|
|
2369
|
+
w.contextPercent = 46;
|
|
2370
|
+
await leadTurn($, 't-2');
|
|
2371
|
+
await settle(clock);
|
|
2372
|
+
expect(compacted).toHaveLength(1);
|
|
2373
|
+
expect(compacted[0]).toMatch(/^The lead's context is compacted between turns/);
|
|
2374
|
+
expect(w.asked.filter((a) => /Compact/.test(a.question))).toEqual([]);
|
|
2375
|
+
});
|
|
2376
|
+
|
|
2315
2377
|
test('LOOP-3 an agent whose work is done is never nudged or restarted: it handed back, or its branch waits in the merge queue', async ($, on) => {
|
|
2316
2378
|
const w = project();
|
|
2317
2379
|
w.next = { step: 'wait', args: '', then: [] };
|
|
@@ -2477,7 +2539,9 @@ test("LOOP-11 a reply that waits on the person holds the loop; the dialog asks t
|
|
|
2477
2539
|
await leadSays($, 't-2', 'Before I dispatch ST-9: should its prices include GST?');
|
|
2478
2540
|
await settle(clock);
|
|
2479
2541
|
// The side call read the reply over the lead's own conversation, with the step it answered.
|
|
2480
|
-
|
|
2542
|
+
// LEAN-6: read by a small model from the reply itself, not a fork over the lead's conversation.
|
|
2543
|
+
expect(w.sideAsks.at(-1).fork).toBeUndefined();
|
|
2544
|
+
expect(w.sideAsks.at(-1).model).toBe('sonnet');
|
|
2481
2545
|
expect(w.sideAsks.at(-1).prompt).toMatch(/prompted you: "Dispatch ST-9/);
|
|
2482
2546
|
expect(w.sideAsks.at(-1).prompt).toMatch(/should its prices include GST\?/);
|
|
2483
2547
|
expect(w.asked.at(-1)).toEqual({
|
|
@@ -2541,7 +2605,8 @@ test("LEAN-3 when a milestone finishes, the person is asked to compact the lead'
|
|
|
2541
2605
|
...w.stories.map((s) => ({ ...s, milestone: 'M1' })),
|
|
2542
2606
|
];
|
|
2543
2607
|
const compacted: string[] = [];
|
|
2544
|
-
|
|
2608
|
+
// Under compact.at (40%), so only the milestone brings the question.
|
|
2609
|
+
w.contextPercent = 30;
|
|
2545
2610
|
on('session.compact', ($: any, e: any) => {
|
|
2546
2611
|
compacted.push(e.instructions);
|
|
2547
2612
|
// A compaction leaves the summary in the conversation's place.
|
|
@@ -2561,6 +2626,7 @@ test("LEAN-3 when a milestone finishes, the person is asked to compact the lead'
|
|
|
2561
2626
|
w.refs += ' m1 refs/heads/main-moved\n';
|
|
2562
2627
|
await clock.advance(2000);
|
|
2563
2628
|
w.answer = 'Compact now';
|
|
2629
|
+
w.contextPercent = 62;
|
|
2564
2630
|
await leadTurn($, 't-2');
|
|
2565
2631
|
await settle(clock);
|
|
2566
2632
|
expect(w.asked.at(-1)?.question).toMatch(
|
|
@@ -2903,6 +2969,17 @@ test('SET-4 with no lane model set, lane agents start as Claude Code decides', a
|
|
|
2903
2969
|
expect(w.spawned[0].model ?? null).toBe(null);
|
|
2904
2970
|
});
|
|
2905
2971
|
|
|
2972
|
+
test("LEAN-7 the security reviewer starts on the reviewers' model, Sonnet unless set, and its own file's model wins", async ($, on) => {
|
|
2973
|
+
const w = project();
|
|
2974
|
+
await start($, on, w);
|
|
2975
|
+
await dispatch($, 'security-reviewer');
|
|
2976
|
+
await dispatch($, 'security-reviewer', 'opus');
|
|
2977
|
+
w.agentFiles['/work/.claude/agents/security-reviewer.md'] =
|
|
2978
|
+
'---\nname: security-reviewer\nmodel: opus\n---\n';
|
|
2979
|
+
await dispatch($, 'security-reviewer');
|
|
2980
|
+
expect(w.spawned.map((e) => e.model ?? null)).toEqual(['sonnet', 'opus', null]);
|
|
2981
|
+
});
|
|
2982
|
+
|
|
2906
2983
|
// --- the reviewer -----------------------------------------------------------------------------------
|
|
2907
2984
|
|
|
2908
2985
|
/** ST-4 finished on web/st-4, the reviewer on. */
|
|
@@ -4359,7 +4436,7 @@ test('a live provider says so in the Team pane, by its own name, and has a Drift
|
|
|
4359
4436
|
w.team = {
|
|
4360
4437
|
...teamOf('kim'),
|
|
4361
4438
|
provider: {
|
|
4362
|
-
name: '
|
|
4439
|
+
name: 'code-kit hub',
|
|
4363
4440
|
live: true,
|
|
4364
4441
|
account: 'kim',
|
|
4365
4442
|
detail: 'claims, leases and intents as they happen',
|
|
@@ -4371,7 +4448,7 @@ test('a live provider says so in the Team pane, by its own name, and has a Drift
|
|
|
4371
4448
|
await teamCmd($);
|
|
4372
4449
|
const ui = await pane($, TEAM);
|
|
4373
4450
|
expect((await ui.find({ key: 'team-provider' }))?.text).toBe(
|
|
4374
|
-
'Through
|
|
4451
|
+
'Through code-kit hub · live · signed in as kim: claims, leases and intents as they happen',
|
|
4375
4452
|
);
|
|
4376
4453
|
await press($, 'team-tab-drift', TEAM);
|
|
4377
4454
|
expect(
|
|
@@ -4385,7 +4462,7 @@ test("with a live provider (the hub), the band says when it fell back to GitHub,
|
|
|
4385
4462
|
const at = new Date().toISOString();
|
|
4386
4463
|
w.team = {
|
|
4387
4464
|
...teamOf('kim'),
|
|
4388
|
-
fallback: "
|
|
4465
|
+
fallback: "code-kit hub can't be reached.",
|
|
4389
4466
|
sessions: [
|
|
4390
4467
|
{
|
|
4391
4468
|
login: 'kim',
|
|
@@ -4435,7 +4512,7 @@ test("with a live provider (the hub), the band says when it fell back to GitHub,
|
|
|
4435
4512
|
expect(w.opened).toEqual([TEAM]);
|
|
4436
4513
|
const ui = await pane($, TEAM);
|
|
4437
4514
|
expect((await ui.find({ key: 'team-provider' }))?.text).toMatch(
|
|
4438
|
-
/can't be reached \(
|
|
4515
|
+
/can't be reached \(code-kit hub can't be reached\.\): claims are checked through GitHub/,
|
|
4439
4516
|
);
|
|
4440
4517
|
expect((await ui.find({ key: 'team-drift-web/st-9' }))?.text).toMatch(
|
|
4441
4518
|
/web\/st-9 · kim[\s\S]*blocker · spec docs\/specs\/02-booking\.md: BOOK-2 now holds prices in cents\./,
|
|
@@ -4461,6 +4538,41 @@ test('TEAM-12 with a board, the mod syncs it in the background, at most every 10
|
|
|
4461
4538
|
expect(syncs()).toBe(2);
|
|
4462
4539
|
});
|
|
4463
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
|
+
|
|
4464
4576
|
test('TRACE-2 a project with more requirements than a list holds still draws its trail', async ($, on) => {
|
|
4465
4577
|
const w = project();
|
|
4466
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}`]] : []),
|