@warren-dean/code-kit 0.20.0 → 0.22.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 +24 -0
- package/README.md +75 -50
- package/bin/code-kit.mjs +364 -25
- package/hooks/guard-agent.mjs +36 -0
- package/hooks/guard-bash.mjs +67 -13
- package/hooks/guard-paths.mjs +14 -2
- package/hooks/hooks.json +20 -0
- package/hooks/lane-audit.mjs +38 -1
- package/hooks/lib/adapters/hub/client.mjs +15 -0
- package/hooks/lib/checks.mjs +5 -1
- package/hooks/lib/config.mjs +149 -6
- package/hooks/lib/decisionstore.mjs +89 -0
- package/hooks/lib/harness.mjs +7 -0
- package/hooks/lib/hook.mjs +7 -0
- package/hooks/lib/instructions.mjs +46 -13
- package/hooks/lib/laneclaims.mjs +89 -0
- package/hooks/lib/layers.mjs +11 -0
- package/hooks/lib/loop-state.mjs +20 -2
- package/hooks/lib/next.mjs +34 -1
- package/hooks/lib/plan.mjs +3 -1
- package/hooks/lib/questions.mjs +23 -5
- package/hooks/lib/regression.mjs +2 -1
- package/hooks/lib/rules.mjs +20 -13
- package/hooks/lib/schedule.mjs +86 -0
- package/hooks/lib/sessions.mjs +223 -0
- package/hooks/lib/snapshot.mjs +80 -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/decisions.mjs +1 -28
- package/hooks/mod/loop.mjs +8 -0
- package/hooks/mod/mission.mjs +21 -19
- package/hooks/mod/register.mjs +117 -40
- package/hooks/mod/register.test.ts +248 -73
- package/hooks/mod/review.mjs +1 -0
- package/hooks/mod/view.mjs +98 -47
- package/hooks/post-edit-check.mjs +8 -1
- package/hooks/session.mjs +24 -0
- package/hooks/stop-check.mjs +23 -3
- package/package.json +1 -1
- package/skills/decide/SKILL.md +1 -1
- package/skills/dispatch/SKILL.md +2 -2
- package/skills/init/SKILL.md +17 -3
- package/skills/lead/SKILL.md +28 -0
- package/skills/next/SKILL.md +2 -2
- package/skills/review/SKILL.md +2 -1
- package/skills/spec-design/SKILL.md +10 -7
- package/templates/agent.md +1 -0
- package/templates/claude-md.md +5 -1
- package/templates/plan.md +2 -1
|
@@ -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 }));
|
package/hooks/mod/decisions.mjs
CHANGED
|
@@ -8,7 +8,6 @@ import { approvalLabel, askWords, commandItself } from './view.mjs';
|
|
|
8
8
|
export const DECISION_KINDS = new Set([
|
|
9
9
|
'held',
|
|
10
10
|
'approvals',
|
|
11
|
-
'stalled',
|
|
12
11
|
'proposal',
|
|
13
12
|
'queue',
|
|
14
13
|
'review',
|
|
@@ -20,7 +19,6 @@ export const DECISION_KINDS = new Set([
|
|
|
20
19
|
'teamDrift',
|
|
21
20
|
'question',
|
|
22
21
|
'brief',
|
|
23
|
-
'denied',
|
|
24
22
|
'report',
|
|
25
23
|
'waiting',
|
|
26
24
|
]);
|
|
@@ -51,10 +49,6 @@ const EFFECT = {
|
|
|
51
49
|
answer: 'Type your answer; the lead is told it and carries on.',
|
|
52
50
|
answerBrief: "Type your answer; the lead records it in the brief's decisions and builds on it.",
|
|
53
51
|
laterBrief: 'It waits; it comes back next session.',
|
|
54
|
-
runDenied:
|
|
55
|
-
'Puts it in your prompt after !, so you run it yourself; press Enter to run it, then Done it.',
|
|
56
|
-
deniedDone: 'The lead is told you ran it, or allowed it, and carries on.',
|
|
57
|
-
deniedNo: "The lead is told you won't run it now, and carries on with other work.",
|
|
58
52
|
answerLead: 'The lead is told your answer and carries on.',
|
|
59
53
|
answerLeadText: 'Type your answer; the lead is told it and carries on.',
|
|
60
54
|
askAgain: 'The lead is asked to take this step again now.',
|
|
@@ -93,17 +87,6 @@ function eachDecision(line) {
|
|
|
93
87
|
text: `From your brief: ${b.question}`,
|
|
94
88
|
brief: b,
|
|
95
89
|
}));
|
|
96
|
-
if (line.kind === 'denied')
|
|
97
|
-
return (line.denials ?? [line.denial]).map((d) => ({
|
|
98
|
-
...line,
|
|
99
|
-
text: `Claude Code refused ${d.who}'s ${d.what}`,
|
|
100
|
-
denial: d,
|
|
101
|
-
actions: [
|
|
102
|
-
...(d.command ? [{ id: 'runDenied', label: 'Put it in the prompt' }] : []),
|
|
103
|
-
{ id: 'deniedDone', label: 'Done it' },
|
|
104
|
-
{ id: 'deniedNo', label: 'Not now' },
|
|
105
|
-
],
|
|
106
|
-
}));
|
|
107
90
|
if (line.kind === 'report')
|
|
108
91
|
return (line.reports ?? [line.report]).map((r) => ({ ...line, text: r.title, report: r }));
|
|
109
92
|
return [line];
|
|
@@ -164,7 +147,6 @@ const TAG = {
|
|
|
164
147
|
notice: 'notice',
|
|
165
148
|
question: 'question',
|
|
166
149
|
brief: 'your brief',
|
|
167
|
-
denied: 'refused',
|
|
168
150
|
report: 'bug report',
|
|
169
151
|
waiting: 'the lead',
|
|
170
152
|
};
|
|
@@ -211,11 +193,6 @@ function headlineOf(line, story) {
|
|
|
211
193
|
headline: line.brief.question,
|
|
212
194
|
sub: `open in ${line.brief.path}${line.brief.blocks ? ` · holds up ${line.brief.blocks}` : ''}`,
|
|
213
195
|
};
|
|
214
|
-
if (line.kind === 'denied')
|
|
215
|
-
return {
|
|
216
|
-
headline: `Claude Code refused ${line.denial.who}'s call`,
|
|
217
|
-
sub: clip(line.denial.command ?? line.denial.what, 80),
|
|
218
|
-
};
|
|
219
196
|
if (line.kind === 'report')
|
|
220
197
|
return { headline: line.report.title, sub: "drafted by an agent, for code-kit's GitHub" };
|
|
221
198
|
if (line.kind === 'waiting' && line.waiting.question)
|
|
@@ -261,8 +238,6 @@ function whyOf(line, check) {
|
|
|
261
238
|
);
|
|
262
239
|
if (line.kind === 'brief')
|
|
263
240
|
return `It's an open question in ${line.brief.path}, left for you: what gets built depends on your answer. Not sure what it means? Ask about it when you answer.`;
|
|
264
|
-
if (line.kind === 'denied')
|
|
265
|
-
return `Claude Code's own permission check refused it${line.denial.reason ? ` (${line.denial.reason})` : ''}, which code-kit can't approve past: only you can run it or allow it.`;
|
|
266
241
|
if (line.kind === 'waiting' && line.waiting.kind === 'person')
|
|
267
242
|
return "The lead's last reply waits on you, so the loop doesn't prompt it again until you answer, here or in the chat.";
|
|
268
243
|
if (line.kind === 'waiting' && line.waiting.kind === 'stopped')
|
|
@@ -391,7 +366,7 @@ export function contextOf(card) {
|
|
|
391
366
|
}
|
|
392
367
|
|
|
393
368
|
/** DECIDE-4: the kinds of decision settled with a choice, asked in Claude Code's own question dialog. */
|
|
394
|
-
export const NATIVE_KINDS = new Set(['proposal', '
|
|
369
|
+
export const NATIVE_KINDS = new Set(['proposal', 'teamStale', 'question', 'waiting']);
|
|
395
370
|
|
|
396
371
|
/** One sentence ended as a question asks. */
|
|
397
372
|
const asked = (text, question) => `${String(text).replace(/[.\s]+$/, '')}. ${question}`;
|
|
@@ -461,8 +436,6 @@ const NOTIFY_TITLE = {
|
|
|
461
436
|
question: 'the lead asks you',
|
|
462
437
|
brief: 'a question in your brief',
|
|
463
438
|
waiting: 'the lead waits on you',
|
|
464
|
-
stalled: 'a lane stalled',
|
|
465
|
-
denied: 'Claude Code refused a call',
|
|
466
439
|
proposal: 'a step to approve',
|
|
467
440
|
queue: 'a merge for you',
|
|
468
441
|
};
|
package/hooks/mod/loop.mjs
CHANGED
|
@@ -218,6 +218,14 @@ export function restartPrompt({ type, id, story, minutes }) {
|
|
|
218
218
|
return `${type} (agent ${id}) on ${story} has made no tool call for ${minutes} minutes. Stop it with TaskStop, then dispatch ${story} again with the same brief on the same branch: /code-kit:dispatch ${story}`;
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
/**
|
|
222
|
+
* WHO-4: a lane stalled past its restarts is the lead's to sort out, not the person's: what it tried,
|
|
223
|
+
* and the lead's three ways on (a narrower brief, smaller stories, or the story handed back).
|
|
224
|
+
*/
|
|
225
|
+
export function stalledPrompt({ type, id, lane, story, restarts, last = null }) {
|
|
226
|
+
return `${type} (agent ${id}) on ${story} has stalled again after ${restarts} restart${restarts === 1 ? '' : 's'}${last ? `; its last call was ${last}` : ''}. Stop it with TaskStop, read its report and what it committed on its branch, then decide: dispatch ${story} again with a brief that names what it got stuck on, split ${story} into smaller stories in the plan, or hand it back to the ${lane} lane's queue with the reason. Ask the person only if the story needs a product decision.`;
|
|
227
|
+
}
|
|
228
|
+
|
|
221
229
|
/** The message a quiet agent gets (LOOP-3). */
|
|
222
230
|
export const nudgeText = (minutes) =>
|
|
223
231
|
`No tool call for ${minutes} minutes: report where you are, or carry on.`;
|