@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +22 -0
- package/README.md +34 -29
- package/bin/code-kit.mjs +240 -8
- package/hooks/guard-bash.mjs +45 -3
- package/hooks/guard-paths.mjs +3 -2
- package/hooks/hooks.json +22 -0
- package/hooks/lib/adapters/hub/client.mjs +723 -0
- package/hooks/lib/adapters/hub/index.mjs +25 -0
- package/hooks/lib/adapters/index.mjs +35 -0
- package/hooks/lib/checks.mjs +5 -1
- package/hooks/lib/config.mjs +1 -1
- package/hooks/lib/instructions.mjs +46 -13
- package/hooks/lib/layers.mjs +11 -0
- package/hooks/lib/plan.mjs +24 -0
- package/hooks/lib/rules.mjs +2 -1
- package/hooks/lib/snapshot.mjs +424 -0
- package/hooks/lib/standards.mjs +450 -0
- package/hooks/lib/team.mjs +55 -9
- package/hooks/lib/teamfacts.mjs +10 -6
- package/hooks/lib/verify.mjs +23 -1
- package/hooks/mod/register.mjs +35 -0
- package/hooks/mod/register.test.ts +49 -4
- package/hooks/mod/review.mjs +1 -0
- package/hooks/mod/teamview.mjs +8 -2
- package/hooks/mod/view.mjs +36 -0
- package/hooks/post-edit-check.mjs +5 -1
- package/hooks/stop-check.mjs +11 -3
- package/hooks/team-session.mjs +57 -0
- package/package.json +1 -1
- package/skills/init/SKILL.md +16 -2
- package/skills/next/SKILL.md +1 -1
- package/skills/review/SKILL.md +2 -1
- package/skills/spec-design/SKILL.md +8 -6
- package/skills/team-init/SKILL.md +3 -3
- package/templates/agent.md +1 -0
- package/templates/claude-md.md +2 -0
|
@@ -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
|
+
}
|
package/hooks/lib/team.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
4
4
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
6
|
import { matchesAny } from './glob.mjs';
|
|
7
|
+
import { providerAdapterOf } from './adapters/index.mjs';
|
|
7
8
|
import {
|
|
8
9
|
isHandle,
|
|
9
10
|
isStoryId,
|
|
@@ -57,19 +58,24 @@ export function teamProblems(c) {
|
|
|
57
58
|
);
|
|
58
59
|
if (t.intents !== undefined && !(typeof t.intents === 'string' && t.intents.trim()))
|
|
59
60
|
p.push('"team.intents" must name the discussion category intents go in');
|
|
61
|
+
const adapter = providerAdapterOf(t.provider);
|
|
60
62
|
if (
|
|
61
63
|
t.provider !== undefined &&
|
|
62
64
|
t.provider !== 'github' &&
|
|
65
|
+
!(adapter && !adapter.problem) &&
|
|
63
66
|
!(
|
|
64
67
|
isObj(t.provider) &&
|
|
68
|
+
!adapter &&
|
|
65
69
|
typeof t.provider.command === 'string' &&
|
|
66
70
|
t.provider.command.trim() &&
|
|
67
71
|
(t.provider.fallback === undefined || t.provider.fallback === 'github')
|
|
68
72
|
)
|
|
69
73
|
)
|
|
70
74
|
p.push(
|
|
71
|
-
'"team.provider" must be "github" or { "command": "<the provider\'s command>", "fallback"?: "github" }',
|
|
75
|
+
'"team.provider" must be "github" or { "command": "<the provider\'s command>", "fallback"?: "github" }, or a hosted provider in preview: { "<its name>": "<its https address>" }',
|
|
72
76
|
);
|
|
77
|
+
if (t.report !== undefined && typeof t.report !== 'boolean')
|
|
78
|
+
p.push('"team.report" must be true or false');
|
|
73
79
|
}
|
|
74
80
|
for (const [name, lane] of Object.entries(isObj(c.lanes) ? c.lanes : {})) {
|
|
75
81
|
if (!isObj(lane) || lane.owners === undefined) continue;
|
|
@@ -86,12 +92,26 @@ export function teamProblems(c) {
|
|
|
86
92
|
*/
|
|
87
93
|
export function teamOf(raw) {
|
|
88
94
|
if (!isObj(raw?.team)) return null;
|
|
95
|
+
// HUBR-6: a hosted provider's adapter runs as a command, with GitHub as its fallback.
|
|
96
|
+
const adapter = providerAdapterOf(raw.team.provider);
|
|
97
|
+
const hosted = adapter && !adapter.problem ? adapter : null;
|
|
89
98
|
return {
|
|
90
99
|
techLead: raw.team.techLead,
|
|
91
100
|
staleDays: raw.team.staleDays ?? 3,
|
|
92
|
-
|
|
101
|
+
// HUBR-7: a hosted provider keeps intents; Discussions only when the team mirrors them there.
|
|
102
|
+
intents: raw.team.intents ?? (hosted ? null : 'Intents'),
|
|
93
103
|
board: raw.team.board ?? null,
|
|
94
|
-
|
|
104
|
+
// HUBR-4: whether the project reports its facts to a provider that isn't GitHub.
|
|
105
|
+
report: raw.team.report !== false,
|
|
106
|
+
provider: hosted
|
|
107
|
+
? {
|
|
108
|
+
command: hosted.adapter.command(hosted.url),
|
|
109
|
+
fallback: 'github',
|
|
110
|
+
adapter: hosted.adapter.key,
|
|
111
|
+
url: hosted.url,
|
|
112
|
+
name: hosted.adapter.label,
|
|
113
|
+
}
|
|
114
|
+
: (raw.team.provider ?? 'github'),
|
|
95
115
|
owners: Object.fromEntries(
|
|
96
116
|
Object.entries(raw.lanes ?? {})
|
|
97
117
|
.filter(([, l]) => Array.isArray(l?.owners))
|
|
@@ -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)
|
|
139
|
-
//
|
|
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
|
|
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
|
-
/**
|
|
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:
|
|
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
|
}
|
package/hooks/lib/teamfacts.mjs
CHANGED
|
@@ -122,17 +122,21 @@ export function decisionRecord(d, techLead) {
|
|
|
122
122
|
.replace(/^Touches:.*$/m, '')
|
|
123
123
|
.replace(/Raised with code-kit by @\S+ before any code[^\n]*/, '')
|
|
124
124
|
.trim();
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
.
|
|
125
|
+
// Who settled it: the provider says (a lane owner whose paths it touches may agree it with a hosted
|
|
126
|
+
// provider), or the tech lead, who alone settles intents through GitHub.
|
|
127
|
+
const by = d.settledBy ?? d.settled_by ?? techLead;
|
|
128
|
+
const agreed =
|
|
129
|
+
(d.comments ?? [])
|
|
130
|
+
.filter((c) => c.author?.login === by && /^Agreed by @/.test(c.body))
|
|
131
|
+
.at(-1)
|
|
132
|
+
?.body.replace(/^Agreed by @[\w-]+(?:, [^:.]*)?(: |\.)?/, '')
|
|
133
|
+
.trim() || (typeof d.why === 'string' ? d.why.trim() : '');
|
|
130
134
|
return [
|
|
131
135
|
`# ${d.title}`,
|
|
132
136
|
'',
|
|
133
137
|
'**Status:** agreed',
|
|
134
138
|
`**Date:** ${date}`,
|
|
135
|
-
`**Raised by:** @${d.author?.login ?? 'someone'} · **Agreed by:** @${
|
|
139
|
+
`**Raised by:** @${d.author?.login ?? 'someone'} · **Agreed by:** @${by}`,
|
|
136
140
|
`**Discussion:** ${d.url}`,
|
|
137
141
|
'',
|
|
138
142
|
'## What changes, and why',
|