@warren-dean/code-kit 0.19.0 → 0.21.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,450 @@
1
+ // A project's standards (docs/specs/22-standards.md): named engineering rules, each graded (required or
2
+ // recommended) and saying how firmly code-kit applies it: `enforced` (an existing rule refuses the
3
+ // change), `checked` (the reviewer and the lead's review look for it) or `advice` (agents are told).
4
+ // Read from the standards file, merged with any an organisation's team provider supplies (STD-6).
5
+ // `parseStandards` and `standardsProblems` are also what a provider uses to check an organisation's
6
+ // standards files, so a project and its organisation are held to the same format.
7
+ import { execFileSync } from 'node:child_process';
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+ import { matchesAny } from './glob.mjs';
11
+ import { globsOverlap } from './teamfacts.mjs';
12
+
13
+ export const STANDARDS_FILE = 'docs/standards.md';
14
+ /** The standards an organisation's provider supplied, as last read (STD-6). */
15
+ export const INHERITED_FILE = '.claude/state/standards-inherited.json';
16
+ /** The approval that guards the standards file (STD-5). */
17
+ export const STANDARDS_APPROVAL = 'standards';
18
+
19
+ export const CATEGORIES = {
20
+ PRI: 'principles',
21
+ PAT: 'patterns',
22
+ ARC: 'architecture',
23
+ CON: 'conventions',
24
+ POL: 'policies',
25
+ };
26
+ export const LEVELS = ['required', 'recommended'];
27
+ /** How firmly a standard is applied, weakest first. */
28
+ export const APPLIED = ['advice', 'checked', 'enforced'];
29
+ export const SOURCES = ['spec', 'decision', 'threat-model', 'code', 'organisation', 'other'];
30
+ export const ORIGINS = ['own', 'inherited', 'extended', 'tightened', 'waived'];
31
+ const RULE_KINDS = ['layers', 'protected', 'check', 'shell'];
32
+
33
+ /** Where the project keeps its standards: `docs.standards`, or docs/standards.md. */
34
+ export const standardsFile = (config) => config?.docs?.standards ?? STANDARDS_FILE;
35
+
36
+ const ID = /^[A-Z]{2,5}-\d+$/;
37
+ const HEADING = /^#{2,4}\s+([A-Z]{2,5}-\d+)\s+(.+?)\s*#*\s*$/;
38
+ const FIELDS = {
39
+ level: 'level',
40
+ applied: 'applied',
41
+ layers: 'layers',
42
+ layer: 'layers',
43
+ why: 'why',
44
+ source: 'source',
45
+ 'enforced by': 'enforcedBy',
46
+ owner: 'owner',
47
+ version: 'version',
48
+ };
49
+ const clean = (v) =>
50
+ String(v ?? '')
51
+ .replace(/`/g, '')
52
+ .trim();
53
+
54
+ /** What a source says about where a standard came from: its kind, and its evidence's counts. */
55
+ export function sourceOf(text) {
56
+ const t = String(text ?? '');
57
+ const counts = t.match(/(\d+)\s+of\s+(\d+)/);
58
+ const kind = /threat model/i.test(t)
59
+ ? 'threat-model'
60
+ : /\bdecision/i.test(t)
61
+ ? 'decision'
62
+ : /organi[sz]ation|inherited/i.test(t)
63
+ ? 'organisation'
64
+ : /\bcode\b|\bmodules?\b|\bfiles?\b/i.test(t) || counts
65
+ ? 'code'
66
+ : /\bspecs?\b|requirement|architecture|principles|brief/i.test(t)
67
+ ? 'spec'
68
+ : 'other';
69
+ return {
70
+ kind,
71
+ count: counts ? Number(counts[1]) : null,
72
+ of: counts ? Number(counts[2]) : null,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * The rule an `enforced by` names: `layers`, `layers (domain may not import infrastructure)`,
78
+ * `layers (web may not import the package pg)`, `protected (contracts)`, `check (lint)` or
79
+ * `shell (deploys)`. Null when it names none of these.
80
+ */
81
+ export function ruleOf(text) {
82
+ const m = clean(text).match(/^(layers?|protected|check|shell)\b\s*(?:\((.*)\)|(.*))$/i);
83
+ if (!m) return null;
84
+ const kind = m[1].toLowerCase() === 'layer' ? 'layers' : m[1].toLowerCase();
85
+ const arg = clean(m[2] ?? m[3] ?? '');
86
+ if (kind !== 'layers') return arg ? { kind, name: arg } : { kind };
87
+ if (!arg) return { kind };
88
+ const pkg = arg.match(/^(\S+)\s+may not import the package\s+(\S+)$/i);
89
+ if (pkg) return { kind, from: pkg[1], package: pkg[2] };
90
+ const layer = arg.match(/^(\S+)\s+may not (?:import|depend on)\s+(\S+)$/i);
91
+ if (layer) return { kind, from: layer[1], to: layer[2] };
92
+ return { kind, from: arg.split(/\s+/)[0] };
93
+ }
94
+
95
+ /**
96
+ * The standards in a file's text: `### <id> <title>` headings, each followed by its fields as lines
97
+ * (`level: required`, `- **Applied:** checked`). `{ standards, problems }`, the problems being lines
98
+ * that look like fields but can't be read.
99
+ */
100
+ export function parseStandards(text) {
101
+ const standards = [];
102
+ const problems = [];
103
+ let current = null;
104
+ String(text ?? '')
105
+ .split('\n')
106
+ .forEach((line, i) => {
107
+ const h = line.match(HEADING);
108
+ if (h) {
109
+ current = {
110
+ id: h[1],
111
+ title: h[2].trim(),
112
+ level: null,
113
+ applied: null,
114
+ layers: [],
115
+ why: null,
116
+ source: null,
117
+ enforcedBy: null,
118
+ owner: null,
119
+ version: null,
120
+ line: i + 1,
121
+ };
122
+ standards.push(current);
123
+ return;
124
+ }
125
+ if (/^#{1,4}\s/.test(line)) {
126
+ current = null;
127
+ return;
128
+ }
129
+ if (!current) return;
130
+ const f = line
131
+ .replace(/^\s*[-*]\s+/, '')
132
+ .replace(/\*\*/g, '')
133
+ .match(/^\s*([A-Za-z][A-Za-z ]*?)\s*:\s*(.*)$/);
134
+ const key = f && FIELDS[f[1].toLowerCase()];
135
+ if (!key) return;
136
+ const value = f[2].trim();
137
+ if (key === 'layers') {
138
+ const names = clean(value)
139
+ .split(/[,;]|\band\b/)
140
+ .map((n) => n.trim())
141
+ .filter(Boolean);
142
+ current.layers = names.length === 1 && /^all\b/i.test(names[0]) ? [] : names;
143
+ } else if (key === 'level' || key === 'applied') current[key] = clean(value).toLowerCase();
144
+ else current[key] = value;
145
+ });
146
+ return { standards, problems };
147
+ }
148
+
149
+ /** Whether the config has the rule an `enforced by` names; a sentence saying what's missing when not. */
150
+ export function ruleProblem(rule, config) {
151
+ if (!rule) return 'it names no rule code-kit has';
152
+ const layers = config.layers ?? [];
153
+ const layer = (n) => layers.find((l) => l.name === n);
154
+ if (rule.kind === 'layers') {
155
+ if (!layers.length) return 'the config has no layers';
156
+ if (rule.from && !layer(rule.from)) return `the config has no layer "${rule.from}"`;
157
+ if (rule.to) {
158
+ if (!layer(rule.to)) return `the config has no layer "${rule.to}"`;
159
+ if (rule.to === rule.from || layer(rule.from).mayImport.includes(rule.to))
160
+ return `${rule.from} may import ${rule.to} in the config, so nothing refuses it`;
161
+ }
162
+ if (rule.package && !matchesAny(rule.package, layer(rule.from).denyPackages ?? []))
163
+ return `${rule.from}'s denyPackages don't include ${rule.package}`;
164
+ return null;
165
+ }
166
+ if (!rule.name) return `it says ${rule.kind} but not which one`;
167
+ if (rule.kind === 'protected')
168
+ return (config.protected ?? []).some((p) => p.approval === rule.name || p.glob === rule.name)
169
+ ? null
170
+ : `no protected path has the approval or glob "${rule.name}"`;
171
+ if (rule.kind === 'check')
172
+ return (config.checks ?? []).some((c) => c.name === rule.name)
173
+ ? null
174
+ : `no check is named "${rule.name}"`;
175
+ return (config.shell?.block ?? []).some(
176
+ (b) => b.pattern === rule.name || String(b.why).toLowerCase().includes(rule.name.toLowerCase()),
177
+ )
178
+ ? null
179
+ : `no shell.block rule's pattern or why matches "${rule.name}"`;
180
+ }
181
+
182
+ /**
183
+ * Every problem with a list of standards, as sentences an agent can act on (STD-1). Without a config,
184
+ * only the standards themselves are checked: an organisation's provider checks its files this way.
185
+ */
186
+ export function standardsProblems(standards, config = null) {
187
+ const p = [];
188
+ const seen = new Set();
189
+ const layerNames = new Set((config?.layers ?? []).map((l) => l.name));
190
+ for (const s of standards) {
191
+ const at = s.id;
192
+ if (seen.has(s.id))
193
+ p.push(`${at} appears twice: each standard needs its own id. Renumber one of them.`);
194
+ seen.add(s.id);
195
+ const prefix = String(s.id).split('-')[0];
196
+ if (!ID.test(String(s.id)) || !CATEGORIES[prefix])
197
+ p.push(
198
+ `${at} has an unknown category: an id starts with PRI- (principles), PAT- (patterns), ARC- (architecture), CON- (conventions) or POL- (policies).`,
199
+ );
200
+ if (!s.title) p.push(`${at} needs a title: one sentence an agent can act on.`);
201
+ if (!LEVELS.includes(s.level))
202
+ p.push(`${at} needs a level: required or recommended (as "level: required").`);
203
+ if (!APPLIED.includes(s.applied))
204
+ p.push(
205
+ `${at} needs to say how it's applied: enforced, checked or advice (as "applied: checked").`,
206
+ );
207
+ if (prefix === 'PRI' && s.applied === 'enforced')
208
+ p.push(
209
+ `${at} is a principle, and a principle can't be enforced, only checked or advised: mark it checked or advice.`,
210
+ );
211
+ else if (s.applied === 'enforced' && !s.enforcedBy)
212
+ p.push(
213
+ `${at} is enforced, but doesn't say what enforces it: add "enforced by:" naming a layer rule, a protected path's approval, a check or a shell rule, or mark it checked.`,
214
+ );
215
+ else if (s.applied === 'enforced' && config) {
216
+ const why = ruleProblem(ruleOf(s.enforcedBy), config);
217
+ if (why)
218
+ p.push(
219
+ `${at} is enforced by "${s.enforcedBy}", but ${why}. Name a rule .claude/code-kit.json has (${RULE_KINDS.map((k) => `${k} (…)`).join(', ')}), or mark it checked.`,
220
+ );
221
+ }
222
+ if (config && layerNames.size)
223
+ for (const l of s.layers ?? [])
224
+ if (!layerNames.has(l))
225
+ p.push(`${at} names the layer "${l}", which the config doesn't have.`);
226
+ }
227
+ return p;
228
+ }
229
+
230
+ const rank = (applied) => APPLIED.indexOf(applied);
231
+ const live = (w, now) => !w.expires || Date.parse(`${w.expires}T23:59:59Z`) >= now;
232
+
233
+ /** Whether `own` asks less than `base`: a lower level, a softer application, or fewer layers. */
234
+ function weaker(own, base) {
235
+ if (base.level === 'required' && own.level === 'recommended') return true;
236
+ if (base.level === 'required' && rank(own.applied) < rank(base.applied)) return true;
237
+ if (base.level !== 'required') return false;
238
+ if (!base.layers.length) return own.layers.length > 0;
239
+ return own.layers.length > 0 && base.layers.some((l) => !own.layers.includes(l));
240
+ }
241
+
242
+ /**
243
+ * The standards that apply (STD-6): the organisation's, with the project's own extending, tightening
244
+ * or (with a waiver the provider confirms) weakening them, then the project's own. Each carries its
245
+ * `origin`; a waived one carries its `waiver`. `problems` says which weakening has no waiver.
246
+ */
247
+ export function mergeStandards(own, inherited = [], waivers = [], now = Date.now()) {
248
+ const problems = [];
249
+ const mine = new Map(own.map((s) => [s.id, s]));
250
+ const out = [];
251
+ for (const base of inherited) {
252
+ const o = mine.get(base.id);
253
+ mine.delete(base.id);
254
+ if (!o) {
255
+ out.push({ ...base, origin: 'inherited' });
256
+ continue;
257
+ }
258
+ if (weaker(o, base)) {
259
+ const w = waivers.find((x) => x.standard === base.id && live(x, now));
260
+ if (!w) {
261
+ problems.push(
262
+ `${base.id} can't be weakened without a waiver: the organisation has it ${base.level} and ${base.applied}${base.layers.length ? ` for ${base.layers.join(', ')}` : ''}, and this project marks it ${o.level} and ${o.applied}${o.layers.length ? ` for ${o.layers.join(', ')}` : ''}. It applies as the organisation has it; ask for a waiver through the team provider, or drop the change.`,
263
+ );
264
+ out.push({ ...base, origin: 'inherited' });
265
+ continue;
266
+ }
267
+ // A waiver for some paths leaves the standard in full everywhere else.
268
+ out.push(
269
+ (w.paths ?? []).length
270
+ ? { ...base, origin: 'waived', waiver: w }
271
+ : { ...base, ...pick(o), origin: 'waived', waiver: w },
272
+ );
273
+ continue;
274
+ }
275
+ const tighter =
276
+ (base.level === 'recommended' && o.level === 'required') ||
277
+ rank(o.applied) > rank(base.applied);
278
+ out.push({
279
+ ...base,
280
+ ...pick(o),
281
+ layers:
282
+ base.layers.length && o.layers.length ? [...new Set([...base.layers, ...o.layers])] : [],
283
+ origin: tighter ? 'tightened' : 'extended',
284
+ });
285
+ }
286
+ for (const s of mine.values()) out.push({ ...s, origin: 'own' });
287
+ return { standards: out, problems };
288
+ }
289
+
290
+ /** The project's fields that extend an inherited standard: everything it sets. */
291
+ const pick = (o) =>
292
+ Object.fromEntries(
293
+ Object.entries(o).filter(([k, v]) => v !== null && v !== '' && k !== 'line' && k !== 'layers'),
294
+ );
295
+
296
+ /** The main checkout, where every worktree's state is shared. */
297
+ function mainCheckout(root) {
298
+ try {
299
+ const common = execFileSync(
300
+ 'git',
301
+ ['-C', root, 'rev-parse', '--path-format=absolute', '--git-common-dir'],
302
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
303
+ ).trim();
304
+ return common ? dirname(common) : root;
305
+ } catch {
306
+ return root;
307
+ }
308
+ }
309
+
310
+ /** The organisation's standards and waivers as the provider last gave them, or none. */
311
+ export function readInherited(root) {
312
+ try {
313
+ const r = JSON.parse(readFileSync(join(mainCheckout(root), INHERITED_FILE), 'utf8'));
314
+ return {
315
+ standards: Array.isArray(r.standards) ? r.standards : [],
316
+ waivers: Array.isArray(r.waivers) ? r.waivers : [],
317
+ version: r.version ?? null,
318
+ at: r.at ?? null,
319
+ };
320
+ } catch {
321
+ return { standards: [], waivers: [], version: null, at: null };
322
+ }
323
+ }
324
+
325
+ /** Keeps what the provider's `standards()` answered (STD-6), for the hooks and instructions to read. */
326
+ export function writeInherited(root, answer) {
327
+ const file = join(mainCheckout(root), INHERITED_FILE);
328
+ mkdirSync(dirname(file), { recursive: true });
329
+ const norm = (s) => ({
330
+ ...s,
331
+ layers: Array.isArray(s.layers) ? s.layers : [],
332
+ level: s.level ?? null,
333
+ applied: s.applied ?? null,
334
+ });
335
+ writeFileSync(
336
+ file,
337
+ `${JSON.stringify(
338
+ {
339
+ at: new Date().toISOString(),
340
+ version: answer?.version ?? null,
341
+ standards: (answer?.standards ?? []).map(norm),
342
+ waivers: answer?.waivers ?? [],
343
+ },
344
+ null,
345
+ 2,
346
+ )}\n`,
347
+ );
348
+ }
349
+
350
+ /**
351
+ * The standards that apply to the project at `root`: its file's, merged with the organisation's as last
352
+ * read. `{ file, exists, standards, problems }`, `problems` covering both the file and the merge.
353
+ */
354
+ export function projectStandards(root, config) {
355
+ const file = standardsFile(config);
356
+ const path = join(root, file);
357
+ const exists = existsSync(path);
358
+ const own = exists ? parseStandards(readFileSync(path, 'utf8')).standards : [];
359
+ const inherited = readInherited(root);
360
+ const merged = mergeStandards(own, inherited.standards, inherited.waivers);
361
+ return {
362
+ file,
363
+ exists,
364
+ version: inherited.version,
365
+ standards: merged.standards,
366
+ problems: [...standardsProblems(own, config), ...merged.problems],
367
+ };
368
+ }
369
+
370
+ /** The layers a lane's paths fall in: any layer whose paths can name one of the lane's files. */
371
+ export function laneLayers(lane, config) {
372
+ return (config.layers ?? [])
373
+ .filter((l) => l.paths.some((lp) => (lane?.paths ?? []).some((p) => globsOverlap(lp, p))))
374
+ .map((l) => l.name);
375
+ }
376
+
377
+ /** Whether a standard applies to any of `layers` (a standard for all layers applies everywhere). */
378
+ export const appliesTo = (s, layers) =>
379
+ !s.layers?.length || !layers || layers.some((l) => s.layers.includes(l));
380
+
381
+ /** One line an agent acts on: "PAT-1 Data access goes through repositories (required, checked)". */
382
+ export function standardLine(s) {
383
+ const from = s.origin && s.origin !== 'own' ? `, from the organisation` : '';
384
+ const waived = s.waiver
385
+ ? `; waived${s.waiver.paths?.length ? ` for ${s.waiver.paths.join(', ')}` : ''}${s.waiver.expires ? ` until ${s.waiver.expires}` : ''}`
386
+ : '';
387
+ return `${s.id} ${s.title} (${s.level}, ${s.applied}${from}${waived})`;
388
+ }
389
+
390
+ /** Whether an enforced standard's rule is the one a refusal came from. */
391
+ function names(rule, broke) {
392
+ if (!rule || rule.kind !== broke.kind) return false;
393
+ if (rule.kind === 'layers') {
394
+ if (rule.from && rule.from !== broke.from) return false;
395
+ if (rule.to) return rule.to === broke.to;
396
+ if (rule.package) return Boolean(broke.package) && matchesAny(broke.package, [rule.package]);
397
+ return true;
398
+ }
399
+ if (!rule.name) return true;
400
+ if (rule.kind === 'shell')
401
+ return String(broke.name ?? '')
402
+ .toLowerCase()
403
+ .includes(rule.name.toLowerCase());
404
+ return rule.name === broke.name || (rule.kind === 'protected' && rule.name === broke.glob);
405
+ }
406
+
407
+ /**
408
+ * STD-4: the sentence a refusal adds when an enforced standard names the rule that refused, as
409
+ * " This is the project's standard ARC-2: Domain code doesn't import infrastructure." Empty otherwise.
410
+ * `broke` is one rule or several: { kind: 'layers', from, to | package } | { kind: 'protected',
411
+ * name, glob } | { kind: 'check', name } | { kind: 'shell', name }.
412
+ */
413
+ export function standardsNote(standards, broke) {
414
+ const rules = [broke].flat().filter(Boolean);
415
+ const hit = standards.filter(
416
+ (s) => s.applied === 'enforced' && rules.some((b) => names(ruleOf(s.enforcedBy), b)),
417
+ );
418
+ if (!hit.length) return '';
419
+ return hit.length === 1
420
+ ? ` This is the project's standard ${hit[0].id}: ${hit[0].title}.`
421
+ : ` This breaks the project's standards ${hit.map((s) => `${s.id} (${s.title})`).join(', ')}.`;
422
+ }
423
+
424
+ /** The note for the project at `root`, reading its standards; empty when it has none. */
425
+ export function noteFor(root, config, broke) {
426
+ try {
427
+ return standardsNote(projectStandards(root, config).standards, broke);
428
+ } catch {
429
+ return '';
430
+ }
431
+ }
432
+
433
+ /**
434
+ * STD-5: what a change to the standards file takes away: standards removed, and required ones made
435
+ * recommended, applied more softly, or held to fewer layers. Sentences, empty when nothing is weakened.
436
+ */
437
+ export function standardsWeakenings(beforeText, afterText) {
438
+ const before = parseStandards(beforeText).standards;
439
+ const after = new Map(parseStandards(afterText).standards.map((s) => [s.id, s]));
440
+ const out = [];
441
+ for (const b of before) {
442
+ const a = after.get(b.id);
443
+ if (!a) out.push(`${b.id} (${b.title}) is removed`);
444
+ else if (weaker(a, b))
445
+ out.push(
446
+ `${b.id} goes from ${b.level} and ${b.applied}${b.layers.length ? ` for ${b.layers.join(', ')}` : ''} to ${a.level} and ${a.applied}${a.layers.length ? ` for ${a.layers.join(', ')}` : ''}`,
447
+ );
448
+ }
449
+ return out;
450
+ }
@@ -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))
@@ -135,10 +155,15 @@ export function whoami(root, now = Date.now()) {
135
155
  // --- the provider (TEAM-2) ----------------------------------------------------------------------------
136
156
  //
137
157
  // The core talks to the team through one interface: whoami, claim(story, lease), release(story),
138
- // state() and intend(about, touches). The GitHub provider is built in; any other is a command named in
139
- // the config, called with the operation's name and its input as JSON on stdin, answering JSON.
158
+ // state() and intend(about, touches), report(snapshot) (HUBR-1) and standards() (STD-6: an
159
+ // organisation's standards and this project's waivers, `{ standards, waivers, version }`). The GitHub
160
+ // provider is built in; any other is a command named in the config, called with the operation's name
161
+ // and its input as JSON on stdin, answering JSON.
140
162
 
141
163
  const CLAIMS = 'refs/code-kit/claims';
164
+ /** HUBR-7: what an intent waits on while the hosted provider that keeps intents is down. */
165
+ const KEPT =
166
+ "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
167
  const SEEN = 'refs/code-kit/seen-claims';
143
168
  const TEAM_STATE = '.claude/state/team.json';
144
169
  const STATE_SECONDS = 60;
@@ -157,7 +182,7 @@ function claimOf(message) {
157
182
 
158
183
  /**
159
184
  * 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
185
+ * `fallback: "github"` (a hosted provider, such as code-kit hub) hands each operation it
161
186
  * can't reach to the GitHub provider, so claims are still checked while it's down (TEAM-8).
162
187
  */
163
188
  export function teamProvider(root, team) {
@@ -189,6 +214,8 @@ export function teamProvider(root, team) {
189
214
  'reassign',
190
215
  'intent',
191
216
  'since',
217
+ 'report',
218
+ 'standards',
192
219
  ].map((op) => [op, either(op)]),
193
220
  ),
194
221
  forget: () => (command.forget(), github.forget()),
@@ -223,8 +250,14 @@ function githubProvider(root, team) {
223
250
  return null;
224
251
  }
225
252
  };
226
- /** The repository's discussions, the Intents category among its categories: null when unreadable. */
253
+ /**
254
+ * The repository's discussions, the Intents category among its categories: null when unreadable.
255
+ * HUBR-7: a team whose hosted provider keeps its intents, and that mirrors none to Discussions, has
256
+ * none here to read (`kept`).
257
+ */
227
258
  const discussions = () => {
259
+ if (!team.intents)
260
+ return { repoId: null, enabled: false, category: null, intents: [], kept: true };
228
261
  const d = graphql(
229
262
  `
230
263
  query ($owner: String!, $name: String!) {
@@ -452,13 +485,15 @@ function githubProvider(root, team) {
452
485
  provider: {
453
486
  name: 'GitHub',
454
487
  live: false,
455
- detail: `claims are refs on origin, intents are discussions in "${team.intents}"`,
488
+ detail: team.intents
489
+ ? `claims are refs on origin, intents are discussions in "${team.intents}"`
490
+ : 'claims are refs on origin; intents wait for the hosted provider',
456
491
  },
457
492
  // TEAM-13: what the team still needs set up for its claims and intents to work.
458
493
  setup: [
459
494
  ...(who ? [] : ["gh isn't signed in on this machine: run gh auth login."]),
460
495
  ...(claims ? [] : ["The shared repository (origin) can't be reached."]),
461
- ...(found && !found.category
496
+ ...(found && !found.category && !found.kept
462
497
  ? [noCategory(found).replace(/, then raise the intent again\.$/, '.')]
463
498
  : []),
464
499
  ],
@@ -489,6 +524,7 @@ function githubProvider(root, team) {
489
524
  if (!by) return { ok: false, why: 'Sign in to GitHub first (gh auth login).' };
490
525
  const found = discussions();
491
526
  if (!found) return unreachable("GitHub can't be reached, so the intent wasn't raised.");
527
+ if (found.kept) return unreachable(KEPT);
492
528
  if (!found.category) return { ok: false, why: noCategory(found) };
493
529
  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
530
  const made = graphql(
@@ -585,6 +621,7 @@ function githubProvider(root, team) {
585
621
  },
586
622
  /** TEAM-10: one intent with its comments, for its decision record; null when unreadable. */
587
623
  intent(id) {
624
+ if (!team.intents) return null;
588
625
  const d = graphql(
589
626
  `
590
627
  query ($owner: String!, $name: String!, $number: Int!) {
@@ -627,6 +664,7 @@ function githubProvider(root, team) {
627
664
  },
628
665
  /** TEAM-5: the tech lead agrees an intent (resolved), drops it (outdated) or marks it a duplicate. */
629
666
  settle(id, outcome, why = '') {
667
+ if (!team.intents) return unreachable(KEPT);
630
668
  const by = me();
631
669
  if (by !== team.techLead)
632
670
  return {
@@ -689,6 +727,10 @@ function githubProvider(root, team) {
689
727
  provider.forget();
690
728
  return { ok: true, id: Number(id), outcome };
691
729
  },
730
+ // HUBR-1: the free team mode has no server to report to; nothing is written anywhere.
731
+ report: () => ({ ok: true, sent: false }),
732
+ // STD-6: an organisation's standards come from a provider that keeps them; GitHub keeps none.
733
+ standards: () => ({ ok: true, standards: [], waivers: [] }),
692
734
  forget: () => writeCache(root, null),
693
735
  };
694
736
  return provider;
@@ -739,6 +781,10 @@ function commandProvider(root, team, command) {
739
781
  const r = call('since', { at });
740
782
  return Array.isArray(r) ? r : null;
741
783
  },
784
+ // HUBR-1: the project's facts, on stdin as the operation's input.
785
+ report: (snapshot) => call('report', { snapshot }),
786
+ // STD-6: the organisation's standards and the waivers it granted this project.
787
+ standards: () => call('standards'),
742
788
  forget: () => writeCache(root, null),
743
789
  };
744
790
  }
@@ -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',