@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.
@@ -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
- intents: raw.team.intents ?? 'Intents',
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
- provider: raw.team.provider ?? 'github',
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 the code-kit teams hub) hands each operation it
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
- /** The repository's discussions, the Intents category among its categories: null when unreadable. */
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: `claims are refs on origin, intents are discussions in "${team.intents}"`,
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
  }
@@ -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
- const agreed = (d.comments ?? [])
126
- .filter((c) => c.author?.login === techLead && /^Agreed by @/.test(c.body))
127
- .at(-1)
128
- ?.body.replace(/^Agreed by @\S+, the tech lead(: |\.)?/, '')
129
- .trim();
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:** @${techLead}`,
139
+ `**Raised by:** @${d.author?.login ?? 'someone'} · **Agreed by:** @${by}`,
136
140
  `**Discussion:** ${d.url}`,
137
141
  '',
138
142
  '## What changes, and why',
@@ -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
- `${milestone} is done; the build goes on with the next milestone.`,
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
+ }
@@ -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 false;
1203
+ return act.compactAtThreshold();
1181
1204
  }
1182
1205
  const fresh = done.filter((m) => !loop.milestones.has(m));
1183
- if (!fresh.length) return false;
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 false;
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
- const said = await act.sideCall(
1262
- outcomePrompt({ prompt: sent?.prompt ?? null, answer: reply.text }),
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) activityOf(e.agentId, await $.clock.now()).handedBack = true;
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
- expect(w.sideAsks.at(-1).fork).toBe(true);
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
- w.contextPercent = 62;
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: 'the code-kit teams hub',
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 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',
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: "The code-kit teams hub can't be reached.",
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 \(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/,
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) => ({
@@ -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}`]] : []),