@warren-dean/code-kit 0.20.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 +12 -0
- package/README.md +31 -29
- package/bin/code-kit.mjs +95 -4
- package/hooks/guard-bash.mjs +45 -3
- package/hooks/guard-paths.mjs +3 -2
- package/hooks/lib/adapters/hub/client.mjs +15 -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/rules.mjs +2 -1
- package/hooks/lib/snapshot.mjs +74 -2
- package/hooks/lib/standards.mjs +450 -0
- package/hooks/lib/team.mjs +9 -2
- package/hooks/lib/verify.mjs +23 -1
- package/hooks/mod/register.test.ts +4 -0
- package/hooks/mod/review.mjs +1 -0
- package/hooks/post-edit-check.mjs +5 -1
- package/hooks/stop-check.mjs +11 -3
- 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/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
|
@@ -155,8 +155,10 @@ export function whoami(root, now = Date.now()) {
|
|
|
155
155
|
// --- the provider (TEAM-2) ----------------------------------------------------------------------------
|
|
156
156
|
//
|
|
157
157
|
// The core talks to the team through one interface: whoami, claim(story, lease), release(story),
|
|
158
|
-
// state() and intend(about, touches)
|
|
159
|
-
//
|
|
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.
|
|
160
162
|
|
|
161
163
|
const CLAIMS = 'refs/code-kit/claims';
|
|
162
164
|
/** HUBR-7: what an intent waits on while the hosted provider that keeps intents is down. */
|
|
@@ -213,6 +215,7 @@ export function teamProvider(root, team) {
|
|
|
213
215
|
'intent',
|
|
214
216
|
'since',
|
|
215
217
|
'report',
|
|
218
|
+
'standards',
|
|
216
219
|
].map((op) => [op, either(op)]),
|
|
217
220
|
),
|
|
218
221
|
forget: () => (command.forget(), github.forget()),
|
|
@@ -726,6 +729,8 @@ function githubProvider(root, team) {
|
|
|
726
729
|
},
|
|
727
730
|
// HUBR-1: the free team mode has no server to report to; nothing is written anywhere.
|
|
728
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: [] }),
|
|
729
734
|
forget: () => writeCache(root, null),
|
|
730
735
|
};
|
|
731
736
|
return provider;
|
|
@@ -778,6 +783,8 @@ function commandProvider(root, team, command) {
|
|
|
778
783
|
},
|
|
779
784
|
// HUBR-1: the project's facts, on stdin as the operation's input.
|
|
780
785
|
report: (snapshot) => call('report', { snapshot }),
|
|
786
|
+
// STD-6: the organisation's standards and the waivers it granted this project.
|
|
787
|
+
standards: () => call('standards'),
|
|
781
788
|
forget: () => writeCache(root, null),
|
|
782
789
|
};
|
|
783
790
|
}
|
package/hooks/lib/verify.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { CONFIG_FILE } from './config.mjs';
|
|
|
10
10
|
import { isDependencyFile } from './dependencies.mjs';
|
|
11
11
|
import { matchesAny } from './glob.mjs';
|
|
12
12
|
import { layerProblems } from './layers.mjs';
|
|
13
|
+
import { STANDARDS_APPROVAL, standardsFile, standardsWeakenings } from './standards.mjs';
|
|
13
14
|
import { TESTS_APPROVAL, branchWeakenings, fixOf, proveFix } from './regression.mjs';
|
|
14
15
|
import {
|
|
15
16
|
APPROVAL_LOG,
|
|
@@ -165,7 +166,7 @@ function currentBranchOf(root) {
|
|
|
165
166
|
|
|
166
167
|
/**
|
|
167
168
|
* The files changed, every problem with the branch, grouped as { ownership, protected, layers,
|
|
168
|
-
* baseline, design, tests, regression, checks }, and the checks `skipped` because a tool isn't
|
|
169
|
+
* baseline, design, tests, regression, standards, checks }, and the checks `skipped` because a tool isn't
|
|
169
170
|
* installed. `lane` holds the branch to that lane's paths; `checks: false` leaves the project's
|
|
170
171
|
* checks to CI's own steps. `fixes` runs a fix's tests without the fix (REG-1; with the
|
|
171
172
|
* checks unless said); `branch` names the branch when the checkout is detached.
|
|
@@ -191,6 +192,7 @@ export function verifyBranch({
|
|
|
191
192
|
design: [],
|
|
192
193
|
tests: [],
|
|
193
194
|
regression: [],
|
|
195
|
+
standards: [],
|
|
194
196
|
checks: [],
|
|
195
197
|
};
|
|
196
198
|
const { entries, problems: logProblems } = logEntries(root, from);
|
|
@@ -274,6 +276,26 @@ export function verifyBranch({
|
|
|
274
276
|
);
|
|
275
277
|
}
|
|
276
278
|
|
|
279
|
+
// STD-5: removing a standard, or weakening a required one, needs the person's standards approval,
|
|
280
|
+
// which the commit records in the log (the lead's own, delegated or not, doesn't count).
|
|
281
|
+
const stdFile = standardsFile(config);
|
|
282
|
+
if (changed.includes(stdFile)) {
|
|
283
|
+
const weakened = standardsWeakenings(
|
|
284
|
+
showAt(root, from, stdFile) ?? '',
|
|
285
|
+
showAt(root, 'HEAD', stdFile) ?? '',
|
|
286
|
+
);
|
|
287
|
+
const approved = entries.some(
|
|
288
|
+
(e) =>
|
|
289
|
+
e.file === stdFile &&
|
|
290
|
+
e.approval === STANDARDS_APPROVAL &&
|
|
291
|
+
!String(e.reason ?? '').includes('approved by the lead within the delegated rules'),
|
|
292
|
+
);
|
|
293
|
+
if (weakened.length && !approved)
|
|
294
|
+
found.standards.push(
|
|
295
|
+
`${stdFile}: ${weakened.join('; ')}. Removing a standard, or weakening a required one, needs the person's standards approval, which the commit records in ${APPROVAL_LOG}. Restore it, or ask the person.`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
277
299
|
// REG-1: a fix's tests fail without it, as the bug.
|
|
278
300
|
const fix = fixes ? fixOf(root, branch ?? currentBranchOf(root), config) : null;
|
|
279
301
|
if (fix) found.regression.push(...proveFix({ root, from, base, changed, config, fix }));
|
|
@@ -3000,6 +3000,10 @@ test('REVW-1 with the reviewer on, a finished branch has the lead start one in t
|
|
|
3000
3000
|
name: 'reviewer',
|
|
3001
3001
|
tools: ['Read', 'Grep', 'Glob', 'Bash'],
|
|
3002
3002
|
});
|
|
3003
|
+
// STD-4: it checks the changed files against the project's standards, grading what each costs.
|
|
3004
|
+
expect(w.registered[0].prompt).toMatch(
|
|
3005
|
+
/standards --json --for <changed files>.*A broken required standard is a blocker, a broken recommended one a concern, advice at most a nit; cite the standard's id as the rule/,
|
|
3006
|
+
);
|
|
3003
3007
|
await leadTurn($);
|
|
3004
3008
|
await settle(clock);
|
|
3005
3009
|
expect(w.prompts[0]).toMatch(
|
package/hooks/mod/review.mjs
CHANGED
|
@@ -22,6 +22,7 @@ export function reviewerSpec({ cli, checklist, model }) {
|
|
|
22
22
|
`1. Run \`node "${cli}" verify --branch <branch>\` from the branch's worktree if it has one (\`git worktree list\`), otherwise in a throwaway worktree (\`git worktree add .claude/worktrees/reviewer-<n> <branch>\`, removed when you're done). A failing verify is always a blocker.`,
|
|
23
23
|
`2. Read ${checklist} and apply its review checklist to the branch's changes against its base, and to the story's requirements. Skip its steps that act (recording a send-back, merging): those are the lead's.`,
|
|
24
24
|
"3. Run the project's test commands that cover the changed files.",
|
|
25
|
+
`4. Check the changed files against the project's standards for their layers: \`node "${cli}" standards --json --for <changed files>\`. Look for breaches of each one marked checked (an enforced one, the hooks already refuse). A broken required standard is a blocker, a broken recommended one a concern, advice at most a nit; cite the standard's id as the rule.`,
|
|
25
26
|
'',
|
|
26
27
|
'Grade each finding: blocker (it must not merge as it is: a failing check, a requirement not met, a rule broken), concern (worth fixing; the lead decides), nit (small). Say where (path:line), what is wrong and what fixed looks like, and the requirement or rule it cites.',
|
|
27
28
|
'',
|
|
@@ -7,7 +7,8 @@ import { join } from 'node:path';
|
|
|
7
7
|
import { matchesAny } from './lib/glob.mjs';
|
|
8
8
|
import { loadBaseline, newProblems } from './lib/baseline.mjs';
|
|
9
9
|
import { block, start } from './lib/hook.mjs';
|
|
10
|
-
import { layerProblems } from './lib/layers.mjs';
|
|
10
|
+
import { brokenLayerRule, layerProblems } from './lib/layers.mjs';
|
|
11
|
+
import { noteFor } from './lib/standards.mjs';
|
|
11
12
|
import { projectWorktrees, relativeTo, worktreeRoot } from './lib/rules.mjs';
|
|
12
13
|
|
|
13
14
|
const { input, project, config, error } = start();
|
|
@@ -23,6 +24,9 @@ if (!existsSync(join(root, rel))) process.exit(0);
|
|
|
23
24
|
// Violations recorded in the baseline (brownfield code as it was adopted) don't block; new ones do.
|
|
24
25
|
const layers = layerProblems(rel, readFileSync(join(root, rel), 'utf8'), config);
|
|
25
26
|
const problems = newProblems(rel, layers, loadBaseline(project));
|
|
27
|
+
// STD-4: a layer rule a project standard names says which standard it is.
|
|
28
|
+
const note = problems.length ? noteFor(root, config, problems.map(brokenLayerRule)) : '';
|
|
29
|
+
if (note) problems.push(note.trim());
|
|
26
30
|
const quoted = `'${rel.replace(/'/g, `'\\''`)}'`;
|
|
27
31
|
for (const check of config.postEdit) {
|
|
28
32
|
if (!matchesAny(rel, check.files) || matchesAny(rel, check.exclude)) continue;
|
package/hooks/stop-check.mjs
CHANGED
|
@@ -7,7 +7,8 @@ import { join } from 'node:path';
|
|
|
7
7
|
import { loadBaseline, newProblems } from './lib/baseline.mjs';
|
|
8
8
|
import { runChecks } from './lib/checks.mjs';
|
|
9
9
|
import { block, start } from './lib/hook.mjs';
|
|
10
|
-
import { layerProblems } from './lib/layers.mjs';
|
|
10
|
+
import { brokenLayerRule, layerProblems } from './lib/layers.mjs';
|
|
11
|
+
import { noteFor } from './lib/standards.mjs';
|
|
11
12
|
import { actorFor, actorRoot, currentBranch, reportPath } from './lib/rules.mjs';
|
|
12
13
|
import { storyOfBranch } from './lib/teamfacts.mjs';
|
|
13
14
|
import { auditProblems, changedFiles } from './lane-audit.mjs';
|
|
@@ -84,10 +85,17 @@ const layers = changed
|
|
|
84
85
|
newProblems(f, layerProblems(f, readFileSync(join(root, f), 'utf8'), config), baseline),
|
|
85
86
|
);
|
|
86
87
|
if (layers.length)
|
|
87
|
-
fail(
|
|
88
|
+
fail(
|
|
89
|
+
`Imports break the layer rules (.claude/code-kit.json › layers):${noteFor(root, config, layers.map(brokenLayerRule))}`,
|
|
90
|
+
layers.join('\n'),
|
|
91
|
+
);
|
|
88
92
|
|
|
89
93
|
const { failure } = runChecks(config, changed, root, actor);
|
|
90
|
-
if (failure)
|
|
94
|
+
if (failure)
|
|
95
|
+
fail(
|
|
96
|
+
`${failure.title}${noteFor(root, config, { kind: 'check', name: failure.check })}`,
|
|
97
|
+
failure.output,
|
|
98
|
+
);
|
|
91
99
|
|
|
92
100
|
// A lane hands back committed work on its own branch; the lead reviews and merges it.
|
|
93
101
|
if (actor.kind === 'lane' && changed.length) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@warren-dean/code-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Keeps AI coding agents inside your project's rules: lanes, layer rules, protected paths and proof before finish, enforced by Claude Code hooks and in CI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Warren Langeveldt",
|