@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.
@@ -0,0 +1,25 @@
1
+ // code-kit hub as a team provider adapter (code-kit spec 21, HUBR-6): chosen by
2
+ // `"team": { "provider": { "hub": "<https address>" } }`, with GitHub as its fallback. Its client
3
+ // (client.mjs) is code-kit's provider command, the session's live link (the event stream and the team's
4
+ // state on disk) and the person's sign-in. In preview (HUBR-8): nothing outside this folder names it,
5
+ // and code-kit only says its name for a project whose config does.
6
+ import { spawnSync } from 'node:child_process';
7
+ import { dirname, join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { recordPresence, startDaemon, stopDaemon } from './client.mjs';
10
+
11
+ const CLIENT = join(dirname(fileURLToPath(import.meta.url)), 'client.mjs');
12
+ const quoted = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
13
+
14
+ export default {
15
+ key: 'hub',
16
+ label: 'code-kit hub',
17
+ command: (url) => `node ${quoted(CLIENT)} --url ${quoted(url)}`,
18
+ session: {
19
+ start: (root, session, watch, url) => startDaemon(root, session, watch, url),
20
+ end: (root, session) => stopDaemon(root, session),
21
+ },
22
+ presence: (root, session, agents) => recordPresence(root, session, agents),
23
+ run: (args, url) =>
24
+ spawnSync(process.execPath, [CLIENT, '--url', url, ...args], { stdio: 'inherit' }).status ?? 1,
25
+ };
@@ -18,9 +18,44 @@
18
18
  // files(root, paths) → { [path]: { rules } }, open: { command } (its view of a file) }
19
19
  //
20
20
  // The core only ever calls applyAdapters() and activeAdapters(); it never names a tool.
21
+ //
22
+ // Team provider adapters (HUBR-6) are the other kind: a hosted team provider's client, chosen when the
23
+ // config's `team.provider` names it, as `{ "<key>": "<its https address>" }`. Each says:
24
+ //
25
+ // key the provider's key in `team.provider`
26
+ // label what code-kit calls it, only when a config names it (HUBR-8)
27
+ // command (url) => the provider command code-kit runs for each operation (TEAM-2)
28
+ // session { start(root, session, watch, url), end(root, session) }: the session's live link
29
+ // presence (root, session, agents) => void: the session's agents, for the provider
30
+ // run (args, url) => exit code: the client's own commands (sign in, connect, status), for
31
+ // `code-kit team provider …`
32
+ //
33
+ // The core only ever calls providerAdapterOf(); it never names a provider.
21
34
  import contextGraph from './context-graph.mjs';
35
+ import hub from './hub/index.mjs';
22
36
 
23
37
  export const ADAPTERS = [contextGraph];
38
+ export const PROVIDERS = [hub];
39
+
40
+ const isAddress = (v) => typeof v === 'string' && /^https?:\/\/[^\s]+$/.test(v);
41
+
42
+ /**
43
+ * The team provider adapter `team.provider` names, with its address: { adapter, url }, or null when the
44
+ * provider isn't an adapter's (GitHub, or a command). `{ problem }` when it names one wrongly.
45
+ */
46
+ export function providerAdapterOf(provider) {
47
+ if (provider === null || typeof provider !== 'object' || Array.isArray(provider)) return null;
48
+ const adapter = PROVIDERS.find((p) => p.key in provider);
49
+ if (!adapter) return null;
50
+ const extra = Object.keys(provider).filter((k) => k !== adapter.key && k !== 'fallback');
51
+ if (
52
+ !isAddress(provider[adapter.key]) ||
53
+ extra.length ||
54
+ ![undefined, 'github'].includes(provider.fallback)
55
+ )
56
+ return { problem: true };
57
+ return { adapter, url: provider[adapter.key].replace(/\/$/, '') };
58
+ }
24
59
 
25
60
  /** Adapters detected in `root` and not switched off by the config's `adapters` map. */
26
61
  export function activeAdapters(raw, root) {
@@ -86,6 +86,30 @@ export function readStories(root, planPath) {
86
86
  return stories;
87
87
  }
88
88
 
89
+ /**
90
+ * HUBR-5: each milestone in the plan, in order: [{ name, target }], `name` the `## ` heading a story sits
91
+ * under, `target` the date a `**Target:** YYYY-MM-DD` line under that heading gives, before its first
92
+ * story, or null.
93
+ */
94
+ export function readMilestones(root, planPath) {
95
+ const found = [];
96
+ for (const file of markdownFiles(root, planPath)) {
97
+ let current = null;
98
+ for (const line of readFileSync(join(root, file), 'utf8').split('\n')) {
99
+ const heading = line.match(/^##\s+(.+?)\s*$/);
100
+ if (heading) {
101
+ current = { name: heading[1], target: null, open: true };
102
+ found.push(current);
103
+ continue;
104
+ }
105
+ if (/^###\s/.test(line) && current) current.open = false;
106
+ const target = line.match(/^\*\*Target:\*\*\s*(\d{4}-\d{2}-\d{2})\s*$/i);
107
+ if (target && current?.open && !current.target) current.target = target[1];
108
+ }
109
+ }
110
+ return found.map(({ name, target }) => ({ name, target }));
111
+ }
112
+
89
113
  export const storyBranch = (story) => `${story.lane}/${story.id.toLowerCase()}`;
90
114
 
91
115
  /** Requirement IDs each tracked test file names: { id: [file, ...] }. */
@@ -0,0 +1,352 @@
1
+ // HUBR-2: what a project reports to its team provider (docs/specs/21-hub-adapter-and-reports.md): a
2
+ // snapshot of facts code-kit already has (status, timeline, trail, journal's approvals, the merge queue,
3
+ // usage, the rules), as metadata only. SNAPSHOT_SCHEMA is the allow-list: every field a snapshot may
4
+ // hold, and what kind of value it is. A provider validates against the same list, and `validSnapshot`
5
+ // refuses anything else, so file contents, diffs, prompts and messages have nowhere to go.
6
+ import { createHash } from 'node:crypto';
7
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
8
+ import { execFileSync } from 'node:child_process';
9
+ import { dirname, join } from 'node:path';
10
+ import { APPROVAL_LOG } from './rules.mjs';
11
+ import { approverOf } from './journal.mjs';
12
+ import { readQueue } from './queue.mjs';
13
+ import { sendBacks } from './reviews.mjs';
14
+ import { readMilestones, specCheckRows } from './plan.mjs';
15
+ import { storyTimes } from './timeline.mjs';
16
+ import { trail } from './storymap.mjs';
17
+
18
+ export const SNAPSHOT_VERSION = 1;
19
+
20
+ /** A story's stage, as a report names it. */
21
+ export const STAGES = [
22
+ 'planned',
23
+ 'claimed',
24
+ 'building',
25
+ 'review',
26
+ 'sent-back',
27
+ 'queued',
28
+ 'merged',
29
+ 'done',
30
+ ];
31
+ export const AGENT_KINDS = ['lead', 'lane', 'reviewer', 'security-reviewer', 'background'];
32
+
33
+ /**
34
+ * The allow-list. A leaf is a kind: `id` (ST-4, API-2), `handle` (a GitHub login), `name` (a lane, a
35
+ * layer, a milestone, an approval: one line, at most 120 characters), `title` (one line, at most 200),
36
+ * `line` (a send-back's reason: one line, at most 200), `path` (a file path or glob: one line, at most
37
+ * 300), `ms` (a time in milliseconds), `date` (YYYY-MM-DD), `count` (a whole number, 0 or more),
38
+ * `bool`, `enum:a|b`. A trailing `?` allows null. An array holds one shape; an object holds only the
39
+ * keys it lists.
40
+ */
41
+ export const SNAPSHOT_SCHEMA = {
42
+ version: 'count',
43
+ project: { repository: 'name?', base: 'name', codeKit: 'name', at: 'ms' },
44
+ milestones: [{ name: 'name', target: 'date?', stories: ['id'] }],
45
+ stories: [
46
+ {
47
+ id: 'id',
48
+ title: 'title',
49
+ lane: 'name?',
50
+ milestone: 'name?',
51
+ requirements: ['id'],
52
+ dependsOn: ['id'],
53
+ stage: `enum:${STAGES.join('|')}`,
54
+ since: {
55
+ building: 'ms?',
56
+ review: 'ms?',
57
+ sentBack: 'ms?',
58
+ queued: 'ms?',
59
+ merged: 'ms?',
60
+ },
61
+ claimedBy: 'handle?',
62
+ },
63
+ ],
64
+ sendBacks: [{ story: 'id', at: 'ms', reason: 'line?' }],
65
+ requirements: [
66
+ {
67
+ id: 'id',
68
+ stories: ['id'],
69
+ built: ['path'],
70
+ tested: ['path'],
71
+ check: 'enum:done|partial|missing|conflicts|none',
72
+ },
73
+ ],
74
+ approvals: [{ what: 'name', path: 'path?', who: 'enum:person|lead', at: 'ms', branch: 'name?' }],
75
+ usage: [
76
+ {
77
+ story: 'id?',
78
+ kind: `enum:${AGENT_KINDS.join('|')}`,
79
+ input: 'count',
80
+ output: 'count',
81
+ cache: 'count',
82
+ },
83
+ ],
84
+ rules: {
85
+ layers: [{ name: 'name', mayImport: ['name'] }],
86
+ protected: [{ glob: 'path', approval: 'name' }],
87
+ delegated: { protected: ['name'], dependencies: 'bool', merge: 'bool' },
88
+ designGate: 'bool',
89
+ },
90
+ };
91
+
92
+ const ONE_LINE = (max) => (v) => typeof v === 'string' && v.length <= max && !/[\r\n]/.test(v);
93
+ const KINDS = {
94
+ id: (v) => typeof v === 'string' && /^[A-Za-z][A-Za-z0-9]*-\d+$/.test(v),
95
+ handle: (v) => typeof v === 'string' && /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(v),
96
+ name: ONE_LINE(120),
97
+ title: ONE_LINE(200),
98
+ line: ONE_LINE(200),
99
+ path: ONE_LINE(300),
100
+ ms: (v) => Number.isInteger(v) && v > 0,
101
+ date: (v) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v),
102
+ count: (v) => Number.isInteger(v) && v >= 0,
103
+ bool: (v) => typeof v === 'boolean',
104
+ };
105
+
106
+ /** What's wrong with a snapshot against the allow-list, as `path: problem` lines; empty when valid. */
107
+ export function snapshotProblems(value, shape = SNAPSHOT_SCHEMA, at = 'snapshot') {
108
+ if (typeof shape === 'string') {
109
+ const optional = shape.endsWith('?');
110
+ const kind = optional ? shape.slice(0, -1) : shape;
111
+ if (value === null || value === undefined) return optional ? [] : [`${at}: missing`];
112
+ if (kind.startsWith('enum:'))
113
+ return kind.slice(5).split('|').includes(value) ? [] : [`${at}: not one of ${kind.slice(5)}`];
114
+ return KINDS[kind](value) ? [] : [`${at}: not a ${kind}`];
115
+ }
116
+ if (Array.isArray(shape)) {
117
+ if (!Array.isArray(value)) return [`${at}: not a list`];
118
+ return value.flatMap((v, i) => snapshotProblems(v, shape[0], `${at}[${i}]`));
119
+ }
120
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
121
+ return [`${at}: not an object`];
122
+ const extra = Object.keys(value).filter((k) => !(k in shape));
123
+ return [
124
+ ...extra.map((k) => `${at}.${k}: not a field a report may hold`),
125
+ ...Object.entries(shape).flatMap(([k, s]) => snapshotProblems(value[k], s, `${at}.${k}`)),
126
+ ];
127
+ }
128
+
129
+ /** The snapshot's hash, leaving out when it was made: equal snapshots aren't sent twice. */
130
+ export function snapshotHash(snapshot, parts = null) {
131
+ const { project, ...rest } = snapshot;
132
+ const kept = parts ? Object.fromEntries(parts.map((p) => [p, rest[p]])) : rest;
133
+ return createHash('sha256')
134
+ .update(JSON.stringify({ project: { ...project, at: 0 }, ...kept }))
135
+ .digest('hex');
136
+ }
137
+
138
+ /** The parts whose change is sent at once (HUBR-3); usage alone waits for the ten minutes. */
139
+ export const EVENT_PARTS = [
140
+ 'milestones',
141
+ 'stories',
142
+ 'sendBacks',
143
+ 'requirements',
144
+ 'approvals',
145
+ 'rules',
146
+ ];
147
+
148
+ const git = (root, ...args) => {
149
+ try {
150
+ return execFileSync('git', ['-C', root, ...args], {
151
+ encoding: 'utf8',
152
+ stdio: ['ignore', 'pipe', 'ignore'],
153
+ }).trim();
154
+ } catch {
155
+ return '';
156
+ }
157
+ };
158
+
159
+ /** owner/name of the repository's GitHub origin, or null. */
160
+ export function repositoryOf(root) {
161
+ const remote = git(root, 'config', '--get', 'remote.origin.url');
162
+ const m = remote.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
163
+ return m ? `${m[1]}/${m[2]}` : null;
164
+ }
165
+
166
+ /** The main checkout, where the lanes' worktrees share their state. */
167
+ export function mainCheckout(root) {
168
+ const common = git(root, 'rev-parse', '--path-format=absolute', '--git-common-dir');
169
+ return common ? dirname(common) : root;
170
+ }
171
+
172
+ export const USAGE_DIR = '.claude/state/usage';
173
+
174
+ /** The usage every session recorded (USE-1's ledgers, written by the mod): [{ story, kind, … }]. */
175
+ export function recordedUsage(root) {
176
+ const dir = join(mainCheckout(root), USAGE_DIR);
177
+ const sums = new Map();
178
+ let files = [];
179
+ try {
180
+ files = readdirSync(dir).filter((f) => f.endsWith('.json'));
181
+ } catch {
182
+ return [];
183
+ }
184
+ for (const f of files) {
185
+ let entries = [];
186
+ try {
187
+ entries = Object.values(JSON.parse(readFileSync(join(dir, f), 'utf8')).entries ?? {});
188
+ } catch {
189
+ continue;
190
+ }
191
+ for (const e of entries) {
192
+ if (!AGENT_KINDS.includes(e.kind)) continue;
193
+ const story = KINDS.id(e.story) ? e.story : null;
194
+ const key = `${story}|${e.kind}`;
195
+ const was = sums.get(key) ?? { story, kind: e.kind, input: 0, output: 0, cache: 0 };
196
+ for (const k of ['input', 'output', 'cache'])
197
+ was[k] += Number.isInteger(e[k]) && e[k] > 0 ? e[k] : 0;
198
+ sums.set(key, was);
199
+ }
200
+ }
201
+ return [...sums.values()];
202
+ }
203
+
204
+ /** The spec-check report's status for each requirement of a story, from its branch's report. */
205
+ function checkedRows(root, story) {
206
+ const name = `${story.branch.replace(/[^\w.-]+/g, '_')}.md`;
207
+ for (const dir of [story.worktree, root].filter(Boolean)) {
208
+ const file = join(dir, '.claude/state/spec-check', name);
209
+ if (existsSync(file)) return specCheckRows(readFileSync(file, 'utf8'));
210
+ }
211
+ return [];
212
+ }
213
+
214
+ const CHECKS = ['done', 'partial', 'missing', 'conflicts'];
215
+
216
+ /**
217
+ * The snapshot (HUBR-2) from what code-kit already computed: `status` is buildStatus's, `base` the
218
+ * branch stories merge into, `claims` the team's claims as last read, `version` code-kit's.
219
+ */
220
+ export function buildSnapshot({
221
+ root,
222
+ config,
223
+ status,
224
+ base,
225
+ claims = [],
226
+ version,
227
+ now = Date.now(),
228
+ }) {
229
+ const times = new Map(storyTimes(root, status.stories, base).map((t) => [t.id, t]));
230
+ const queued = new Map(
231
+ readQueue(root)
232
+ .filter((e) => e.state === 'waiting' || e.state === 'merging')
233
+ .map((e) => [e.branch, Date.parse(e.passed)]),
234
+ );
235
+ const backs = sendBacks(root);
236
+ const claimed = new Map(claims.map((c) => [c.story, c.by]));
237
+ const ms = (v) => (Number.isInteger(v) && v > 0 ? v : null);
238
+ const line = (s) => (s ? String(s).replace(/\s+/g, ' ').trim().slice(0, 200) || null : null);
239
+
240
+ const stories = status.stories.map((s) => {
241
+ const t = times.get(s.id) ?? {};
242
+ const myBacks = backs.filter((b) => b.branch === s.branch);
243
+ const lastBack = myBacks.length ? Date.parse(myBacks.at(-1).at) : null;
244
+ let stage = 'planned';
245
+ if (s.state === 'done') stage = s.status === 'done' ? 'done' : 'merged';
246
+ else if (s.state === 'sent back') stage = 'sent-back';
247
+ else if (s.state === 'review') stage = queued.has(s.branch) ? 'queued' : 'review';
248
+ else if (s.state === 'in progress') stage = 'building';
249
+ else if (claimed.has(s.id)) stage = 'claimed';
250
+ const reached = STAGES.indexOf(stage);
251
+ return {
252
+ id: s.id,
253
+ title: line(s.title) ?? s.id,
254
+ lane: s.lane || null,
255
+ milestone: s.milestone ? line(s.milestone).slice(0, 120) : null,
256
+ requirements: s.requirements,
257
+ dependsOn: s.dependsOn,
258
+ stage,
259
+ since: {
260
+ building: reached >= STAGES.indexOf('building') ? ms(t.started) : null,
261
+ review:
262
+ reached >= STAGES.indexOf('review') && stage !== 'sent-back' ? ms(t.lastCommit) : null,
263
+ sentBack: ms(lastBack),
264
+ queued: stage === 'queued' ? ms(queued.get(s.branch)) : null,
265
+ merged: ms(t.merged),
266
+ },
267
+ claimedBy: claimed.get(s.id) ?? null,
268
+ };
269
+ });
270
+
271
+ const targets = Object.fromEntries(
272
+ readMilestones(root, config.docs.plan).map((m) => [m.name, m.target]),
273
+ );
274
+ const milestones = [...new Set(status.stories.map((s) => s.milestone).filter(Boolean))].map(
275
+ (m) => ({
276
+ name: line(m).slice(0, 120),
277
+ target: targets[m] ?? null,
278
+ stories: status.stories.filter((s) => s.milestone === m).map((s) => s.id),
279
+ }),
280
+ );
281
+
282
+ const byBranch = new Map(status.stories.map((s) => [s.branch, s.id]));
283
+ const sendBackList = backs
284
+ .filter((b) => byBranch.has(b.branch) && ms(Date.parse(b.at)))
285
+ .map((b) => ({ story: byBranch.get(b.branch), at: Date.parse(b.at), reason: line(b.reason) }));
286
+
287
+ const checks = new Map();
288
+ for (const s of status.stories)
289
+ if (s.branchExists)
290
+ for (const row of checkedRows(root, s))
291
+ if (CHECKS.includes(row.status)) checks.set(row.requirement, row.status);
292
+ const requirements = trail(root, status, base).map((r) => ({
293
+ id: r.id,
294
+ stories: r.stories.map((s) => s.id),
295
+ built: r.files.filter((f) => KINDS.path(f)),
296
+ tested: r.tests.filter((f) => KINDS.path(f)),
297
+ check: checks.get(r.id) ?? 'none',
298
+ }));
299
+
300
+ const approvals = [];
301
+ const seen = new Set();
302
+ for (const raw of git(root, 'show', `${base}:${APPROVAL_LOG}`).split('\n').filter(Boolean)) {
303
+ let e;
304
+ try {
305
+ e = JSON.parse(raw);
306
+ } catch {
307
+ continue;
308
+ }
309
+ if (!e.approval || e.approval === 'lead') continue;
310
+ const at = Date.parse(e.grantedAt ?? e.at);
311
+ const key = `${e.approval}|${e.file ?? ''}|${at}`;
312
+ if (seen.has(key) || !ms(at) || !KINDS.name(e.approval)) continue;
313
+ seen.add(key);
314
+ approvals.push({
315
+ what: e.approval,
316
+ path: KINDS.path(e.file) ? e.file : null,
317
+ who: approverOf(e),
318
+ at,
319
+ branch: KINDS.name(e.branch) ? e.branch : null,
320
+ });
321
+ }
322
+
323
+ const delegate = config.approvals?.delegate ?? null;
324
+ const snapshot = {
325
+ version: SNAPSHOT_VERSION,
326
+ project: {
327
+ repository: repositoryOf(root),
328
+ base,
329
+ codeKit: String(version),
330
+ at: now,
331
+ },
332
+ milestones,
333
+ stories,
334
+ sendBacks: sendBackList,
335
+ requirements,
336
+ approvals,
337
+ usage: recordedUsage(root),
338
+ rules: {
339
+ layers: (config.layers ?? []).map((l) => ({ name: l.name, mayImport: l.mayImport ?? [] })),
340
+ protected: (config.protected ?? [])
341
+ .filter((p) => KINDS.path(p.glob) && KINDS.name(p.approval))
342
+ .map((p) => ({ glob: p.glob, approval: p.approval })),
343
+ delegated: {
344
+ protected: delegate?.protected ?? [],
345
+ dependencies: Boolean(delegate?.dependencies),
346
+ merge: Boolean(delegate?.merge),
347
+ },
348
+ designGate: Boolean(config.designGate),
349
+ },
350
+ };
351
+ return snapshot;
352
+ }
@@ -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',