@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
package/hooks/guard-bash.mjs
CHANGED
|
@@ -21,6 +21,12 @@ import { laneOfBranch, storyOfBranch, teamOf, teamProvider, whoami } from './lib
|
|
|
21
21
|
import { matchesAny } from './lib/glob.mjs';
|
|
22
22
|
import { noteBefore } from './lane-audit.mjs';
|
|
23
23
|
import { TESTS_APPROVAL, commitWeakenings } from './lib/regression.mjs';
|
|
24
|
+
import {
|
|
25
|
+
STANDARDS_APPROVAL,
|
|
26
|
+
noteFor,
|
|
27
|
+
standardsFile,
|
|
28
|
+
standardsWeakenings,
|
|
29
|
+
} from './lib/standards.mjs';
|
|
24
30
|
import {
|
|
25
31
|
APPROVAL_LOG,
|
|
26
32
|
actorFor,
|
|
@@ -30,8 +36,10 @@ import {
|
|
|
30
36
|
askHowTo,
|
|
31
37
|
currentBranch,
|
|
32
38
|
decidedBy,
|
|
39
|
+
delegatedMark,
|
|
33
40
|
dependencyChange,
|
|
34
41
|
dependencyGrants,
|
|
42
|
+
leadDecides,
|
|
35
43
|
useApproval,
|
|
36
44
|
owns,
|
|
37
45
|
protectedEntry,
|
|
@@ -176,7 +184,9 @@ for (const { pattern, why, person, command } of config.shell.block) {
|
|
|
176
184
|
person && actor.kind === 'lead'
|
|
177
185
|
? '\nTo let the lead run it when the person says so in chat, a person sets "approvals": { "lead": true } in the config.'
|
|
178
186
|
: '';
|
|
179
|
-
block(
|
|
187
|
+
block(
|
|
188
|
+
`Blocked: ${why}${noteFor(project, config, { kind: 'shell', name: why })}${hint}\nCommand: ${cmd}`,
|
|
189
|
+
);
|
|
180
190
|
}
|
|
181
191
|
|
|
182
192
|
if (approves && actor.kind !== 'lead')
|
|
@@ -208,10 +218,14 @@ if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+loop\s+(pause|resume)\b`)))
|
|
|
208
218
|
block(
|
|
209
219
|
`Blocked: pausing or resuming the lead loop is the person's own act, from the code-kit band or their terminal. Ask them, and stop.\nCommand: ${cmd}`,
|
|
210
220
|
);
|
|
211
|
-
// LOOP-10: which session
|
|
212
|
-
|
|
221
|
+
// LOOP-10, SESS-2: which session leads is recorded by code-kit's hooks and band, and choosing it is
|
|
222
|
+
// the person's own act.
|
|
223
|
+
if (
|
|
224
|
+
runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+loop\s+(lead|release)\b`)) ||
|
|
225
|
+
runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+sessions\s+(lead|beat)\b`))
|
|
226
|
+
)
|
|
213
227
|
block(
|
|
214
|
-
`Blocked: which session
|
|
228
|
+
`Blocked: which session leads the project is recorded by code-kit, and choosing it is the person's own act (Lead from here, in the code-kit band, or \`code-kit sessions lead\` in their terminal). Carry on with your work.\nCommand: ${cmd}`,
|
|
215
229
|
);
|
|
216
230
|
// TEAM-9: handing someone's claim to someone else is the tech lead's own decision too.
|
|
217
231
|
if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+team\s+reassign\b`)))
|
|
@@ -273,7 +287,7 @@ if (delegatedMerge && !config.approvals.delegate?.merge)
|
|
|
273
287
|
const delegating = /\s--delegated\b/.test(cmd);
|
|
274
288
|
if (approves && delegating && !config.approvals.delegate)
|
|
275
289
|
block(
|
|
276
|
-
`Blocked: this project
|
|
290
|
+
`Blocked: this project keeps every approval for the person ("approvals.delegate" is false). Ask the person to approve it with the \`!\` command from the refusal.\nCommand: ${cmd}`,
|
|
277
291
|
);
|
|
278
292
|
if (approves && !delegating && !forPerson)
|
|
279
293
|
block(
|
|
@@ -302,7 +316,9 @@ if (packages.length) {
|
|
|
302
316
|
// HELM-2, HELM-3: a lane asks the lead, which judges the package on its merits where it decides.
|
|
303
317
|
const names = unapproved.map(dependencyApproval);
|
|
304
318
|
const whose =
|
|
305
|
-
decidedBy(names, config) === 'lead'
|
|
319
|
+
decidedBy(names, config, actor) === 'lead'
|
|
320
|
+
? "is the lead's decision"
|
|
321
|
+
: "needs a person's approval";
|
|
306
322
|
block(
|
|
307
323
|
`Blocked: a new dependency (${unapproved.join(', ')}) ${whose}. ${askHowTo(names, actor, sessionIn ?? project, config, { allows: 'installing it, and the manifest and lockfile changes that makes,' })}\nCommand: ${cmd}`,
|
|
308
324
|
);
|
|
@@ -471,16 +487,47 @@ function recordApprovals() {
|
|
|
471
487
|
session: input.session_id ?? null,
|
|
472
488
|
}),
|
|
473
489
|
);
|
|
490
|
+
// STD-5: removing a standard, or weakening a required one, is the person's own call, never the lead's,
|
|
491
|
+
// delegated or not. The commit waits for their `standards` approval, which records what it weakened.
|
|
492
|
+
const stdFile = standardsFile(config);
|
|
493
|
+
const stdWeak =
|
|
494
|
+
files.includes(stdFile) && !arrivesWithMerge(root, stdFile, { staged: true })
|
|
495
|
+
? standardsWeakenings(
|
|
496
|
+
git('show', `HEAD:${stdFile}`),
|
|
497
|
+
existsSync(join(root, stdFile)) ? readFileSync(join(root, stdFile), 'utf8') : '',
|
|
498
|
+
)
|
|
499
|
+
: [];
|
|
500
|
+
if (stdWeak.length) {
|
|
501
|
+
const granted = approval(root, STANDARDS_APPROVAL, actor);
|
|
502
|
+
if (!granted || granted.reason.includes(delegatedMark('').slice(0, -1))) {
|
|
503
|
+
recordRequest(
|
|
504
|
+
root,
|
|
505
|
+
actor,
|
|
506
|
+
STANDARDS_APPROVAL,
|
|
507
|
+
stdFile,
|
|
508
|
+
`Standards weakened: ${stdWeak.join('; ')}.`,
|
|
509
|
+
);
|
|
510
|
+
const personOnly = { ...config, approvals: { ...config.approvals, delegate: null } };
|
|
511
|
+
block(
|
|
512
|
+
`Blocked: this commit weakens the project's standards in ${stdFile}: ${stdWeak.join('; ')}. Removing a standard, or weakening a required one, is the person's call, never the lead's. Ask them, with what the standard costs the project and your recommendation; they approve it with\n${approvalHowTo(STANDARDS_APPROVAL, actor, root, 'weakening the standards in one commit', personOnly)}`,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
474
516
|
for (const file of files) {
|
|
475
517
|
// Concluding a merge: what arrives as the other branch has it was logged there, with its approvals.
|
|
476
518
|
if (arrivesWithMerge(root, file, { staged: true })) continue;
|
|
477
|
-
|
|
519
|
+
// A weakened standards file is the person's even where the config doesn't protect it.
|
|
520
|
+
const entry =
|
|
521
|
+
protectedEntry(file, config) ??
|
|
522
|
+
(file === stdFile && stdWeak.length
|
|
523
|
+
? { approval: STANDARDS_APPROVAL, why: "the project's standards" }
|
|
524
|
+
: null);
|
|
478
525
|
const granted = entry && approval(root, entry.approval, actor);
|
|
479
526
|
const byDependency = actor.kind === 'lane' && dependencyChange(actor, file, root, config);
|
|
480
527
|
if (entry && !granted && actor.kind !== 'lead' && !byDependency) {
|
|
481
528
|
recordRequest(root, actor, entry.approval, file, entry.why);
|
|
482
529
|
block(
|
|
483
|
-
`Blocked: ${file} is ${entry.why}; committing it needs ${decidedBy(entry.approval, config) === 'lead' ? "the lead's" : "a person's"} approval in force. ${askHowTo(entry.approval, actor, root, config)}`,
|
|
530
|
+
`Blocked: ${file} is ${entry.why}; committing it needs ${decidedBy(entry.approval, config, actor) === 'lead' ? "the lead's" : "a person's"} approval in force.${noteFor(root, config, { kind: 'protected', name: entry.approval, glob: entry.glob })} ${askHowTo(entry.approval, actor, root, config)}`,
|
|
484
531
|
);
|
|
485
532
|
}
|
|
486
533
|
if (granted)
|
|
@@ -488,6 +535,7 @@ function recordApprovals() {
|
|
|
488
535
|
approval: entry.approval,
|
|
489
536
|
reason: granted.reason,
|
|
490
537
|
grantedAt: granted.grantedAt,
|
|
538
|
+
...(file === stdFile && stdWeak.length ? { weakened: stdWeak } : {}),
|
|
491
539
|
});
|
|
492
540
|
else if (byDependency && (entry || !owns(actor, file, config))) {
|
|
493
541
|
const grants = dependencyGrants(root, actor);
|
|
@@ -504,8 +552,9 @@ function recordApprovals() {
|
|
|
504
552
|
grantedAt: null,
|
|
505
553
|
});
|
|
506
554
|
}
|
|
507
|
-
// REG-2: a commit that weakens a test waits for
|
|
508
|
-
//
|
|
555
|
+
// REG-2: a commit that weakens a test waits for a `tests` approval: WHO-2, the lead's on a lane's
|
|
556
|
+
// branch where tests are delegated, otherwise the person's (always on the lead's own). It's recorded in
|
|
557
|
+
// the log, so verify accepts the branch, and used up by this commit.
|
|
509
558
|
const weakened = commitWeakenings(
|
|
510
559
|
root,
|
|
511
560
|
files.filter((f) => !arrivesWithMerge(root, f, { staged: true })),
|
|
@@ -517,16 +566,20 @@ function recordApprovals() {
|
|
|
517
566
|
if (!granted) {
|
|
518
567
|
const [w, ...more] = weakened;
|
|
519
568
|
recordRequest(root, actor, TESTS_APPROVAL, w.file, `A weakened test: ${said(w)}.`);
|
|
569
|
+
const leads = leadDecides(TESTS_APPROVAL, config, actor);
|
|
520
570
|
const personOnly = { ...config, approvals: { ...config.approvals, delegate: null } };
|
|
521
571
|
block(
|
|
522
|
-
`Blocked: ${w.file} is a weakened test (${said(w)}); committing it needs a person's approval in force.
|
|
572
|
+
`Blocked: ${w.file} is a weakened test (${said(w)}); committing it needs ${leads ? "the lead's" : "a person's"} approval in force.
|
|
523
573
|
` +
|
|
524
574
|
(more.length
|
|
525
575
|
? `Also weakened: ${more.map((x) => `${x.file} (${said(x)})`).join('; ')}.
|
|
526
576
|
`
|
|
527
577
|
: '') +
|
|
528
|
-
`A weakened test can hide a regression. Fix the code so the test passes as it was.
|
|
529
|
-
|
|
578
|
+
`A weakened test can hide a regression. Fix the code so the test passes as it was. ` +
|
|
579
|
+
(leads
|
|
580
|
+
? `If the test itself is wrong, the lead decides after reviewing it: ${askHowTo(TESTS_APPROVAL, actor, root, config, { allows: 'weakening tests in one commit', need: 'the test, what you weakened and why the test was wrong, and the story' })}`
|
|
581
|
+
: `If the test itself is wrong, ask the person: ${actor.kind === 'lead' ? "on your own branch nobody else reviews it, so you can't approve it." : "the lead can't approve it here."} They run
|
|
582
|
+
${approvalHowTo(TESTS_APPROVAL, actor, root, 'weakening tests in one commit', personOnly)}`),
|
|
530
583
|
);
|
|
531
584
|
}
|
|
532
585
|
for (const w of weakened)
|
|
@@ -538,6 +591,7 @@ ${approvalHowTo(TESTS_APPROVAL, actor, root, 'weakening tests in one commit', pe
|
|
|
538
591
|
});
|
|
539
592
|
useApproval(root, TESTS_APPROVAL, actor);
|
|
540
593
|
}
|
|
594
|
+
if (stdWeak.length) useApproval(root, STANDARDS_APPROVAL, actor);
|
|
541
595
|
// A dependency approval is used up by the commit that adds its package to a manifest or lockfile.
|
|
542
596
|
const manifests = files.filter((f) => isDependencyFile(f));
|
|
543
597
|
if (manifests.length && actor.kind !== 'readonly') {
|
package/hooks/guard-paths.mjs
CHANGED
|
@@ -6,9 +6,11 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { loadBaseline, newProblems } from './lib/baseline.mjs';
|
|
8
8
|
import { CONFIG_FILE } from './lib/config.mjs';
|
|
9
|
-
import { layerProblems } from './lib/layers.mjs';
|
|
9
|
+
import { brokenLayerRule, layerProblems } from './lib/layers.mjs';
|
|
10
|
+
import { noteFor } from './lib/standards.mjs';
|
|
10
11
|
import { block, start } from './lib/hook.mjs';
|
|
11
12
|
import { teamOf, teamProvider, teamRefusal, whoami } from './lib/team.mjs';
|
|
13
|
+
import { sessionWriteProblem } from './lib/sessions.mjs';
|
|
12
14
|
import { spawnSync } from 'node:child_process';
|
|
13
15
|
import {
|
|
14
16
|
actorFor,
|
|
@@ -37,6 +39,16 @@ if (error) {
|
|
|
37
39
|
const problem = writeProblem(actorFor(input, root, config), rel, root, config);
|
|
38
40
|
if (problem) block(`Blocked: ${problem}`);
|
|
39
41
|
|
|
42
|
+
// SESS-4, SESS-5: another session's task leases the path, or a working session writes where it shouldn't.
|
|
43
|
+
const shared = sessionWriteProblem({
|
|
44
|
+
project,
|
|
45
|
+
root,
|
|
46
|
+
rel,
|
|
47
|
+
actor: actorFor(input, root, config),
|
|
48
|
+
session: input.session_id ?? null,
|
|
49
|
+
});
|
|
50
|
+
if (shared) block(`Blocked: ${shared}`);
|
|
51
|
+
|
|
40
52
|
// TEAM-1, TEAM-4: in a team, the tech lead's paths, other people's lanes, claims and leases.
|
|
41
53
|
const team = teamOf(config);
|
|
42
54
|
if (team) {
|
|
@@ -83,7 +95,7 @@ if (after !== null) {
|
|
|
83
95
|
);
|
|
84
96
|
if (added.length)
|
|
85
97
|
block(
|
|
86
|
-
`Blocked: this edit would break the layer rules (.claude/code-kit.json › layers):\n ${added.join('\n ')}\nDepend only on the layers listed, or ask the lead to change the rule
|
|
98
|
+
`Blocked: this edit would break the layer rules (.claude/code-kit.json › layers):\n ${added.join('\n ')}\nDepend only on the layers listed, or ask the lead to change the rule.${noteFor(root, config, added.map(brokenLayerRule))}`,
|
|
87
99
|
);
|
|
88
100
|
}
|
|
89
101
|
process.exit(0);
|
package/hooks/hooks.json
CHANGED
|
@@ -22,6 +22,16 @@
|
|
|
22
22
|
"timeout": 30
|
|
23
23
|
}
|
|
24
24
|
]
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"matcher": "Agent|Task",
|
|
28
|
+
"hooks": [
|
|
29
|
+
{
|
|
30
|
+
"type": "command",
|
|
31
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/guard-agent.mjs\"",
|
|
32
|
+
"timeout": 10
|
|
33
|
+
}
|
|
34
|
+
]
|
|
25
35
|
}
|
|
26
36
|
],
|
|
27
37
|
"PostToolUse": [
|
|
@@ -53,6 +63,11 @@
|
|
|
53
63
|
"type": "command",
|
|
54
64
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/team-session.mjs\" start",
|
|
55
65
|
"timeout": 10
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"type": "command",
|
|
69
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session.mjs\" start",
|
|
70
|
+
"timeout": 10
|
|
56
71
|
}
|
|
57
72
|
]
|
|
58
73
|
}
|
|
@@ -64,6 +79,11 @@
|
|
|
64
79
|
"type": "command",
|
|
65
80
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/team-session.mjs\" end",
|
|
66
81
|
"timeout": 10
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"type": "command",
|
|
85
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session.mjs\" end",
|
|
86
|
+
"timeout": 10
|
|
67
87
|
}
|
|
68
88
|
]
|
|
69
89
|
}
|
package/hooks/lane-audit.mjs
CHANGED
|
@@ -79,6 +79,26 @@ function takeNote(input) {
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* SESS-5: the files git said a command's checkout, merge or rebase would overwrite, when it refused for
|
|
84
|
+
* uncommitted work: [] when it didn't.
|
|
85
|
+
*/
|
|
86
|
+
export function overwrittenFiles(response) {
|
|
87
|
+
const text = typeof response === 'string' ? response : JSON.stringify(response ?? '');
|
|
88
|
+
const said = text.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
|
89
|
+
const files = [];
|
|
90
|
+
for (const m of said.matchAll(
|
|
91
|
+
/(?:local changes to the following files|untracked working tree files) would be (?:overwritten|removed) by [\w-]+:\n((?:\t[^\n]+\n?)+)/g,
|
|
92
|
+
))
|
|
93
|
+
files.push(
|
|
94
|
+
...m[1]
|
|
95
|
+
.split('\n')
|
|
96
|
+
.map((l) => l.trim())
|
|
97
|
+
.filter(Boolean),
|
|
98
|
+
);
|
|
99
|
+
return [...new Set(files)];
|
|
100
|
+
}
|
|
101
|
+
|
|
82
102
|
export function auditProblems(input, config) {
|
|
83
103
|
const root = actorRoot(input);
|
|
84
104
|
if (!root) return [];
|
|
@@ -99,8 +119,25 @@ export function auditProblems(input, config) {
|
|
|
99
119
|
|
|
100
120
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
101
121
|
const { block, start } = await import('./lib/hook.mjs');
|
|
102
|
-
const { input, config, error } = start();
|
|
122
|
+
const { input, project, config, error } = start();
|
|
103
123
|
if (error) process.exit(0); // guard-paths and stop-check report an invalid config
|
|
124
|
+
// SESS-5: git refused because of uncommitted files another session wrote: say whose they are.
|
|
125
|
+
const overwritten = overwrittenFiles(input.tool_response);
|
|
126
|
+
if (overwritten.length) {
|
|
127
|
+
const { memberOf, writersOf } = await import('./lib/sessions.mjs');
|
|
128
|
+
const whose = Object.entries(writersOf(project, overwritten, input.session_id ?? null));
|
|
129
|
+
if (whose.length)
|
|
130
|
+
block(
|
|
131
|
+
`Git refused because of uncommitted files another session wrote in this checkout:\n ${whose
|
|
132
|
+
.map(
|
|
133
|
+
([f, w]) =>
|
|
134
|
+
`${f}: ${w.map((s) => `${memberOf(s)}${s.task ? `, working on "${s.task.title}"` : ''}${s.live ? '' : ' (ended)'}`).join('; ')}`,
|
|
135
|
+
)
|
|
136
|
+
.join(
|
|
137
|
+
'\n ',
|
|
138
|
+
)}\nAsk that session to commit or stash them; don't stash or discard another session's work. Better, work in a worktree of your own, where no other session's files can block you.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
104
141
|
const problems = auditProblems(input, config);
|
|
105
142
|
if (problems.length) {
|
|
106
143
|
block(
|
|
@@ -40,6 +40,7 @@ const OPS = [
|
|
|
40
40
|
'intent',
|
|
41
41
|
'since',
|
|
42
42
|
'report',
|
|
43
|
+
'standards',
|
|
43
44
|
];
|
|
44
45
|
/** What to run again to sign in: code-kit's own command for the client. */
|
|
45
46
|
const SIGN_IN = 'code-kit team provider signin';
|
|
@@ -158,6 +159,20 @@ export async function provide(op, input, root = process.cwd()) {
|
|
|
158
159
|
}
|
|
159
160
|
const { team: _team, ...body } = input;
|
|
160
161
|
try {
|
|
162
|
+
// STD-6: the organisation's standards are read, not posted. A hub without them answers none.
|
|
163
|
+
if (op === 'standards') {
|
|
164
|
+
const { status, json } = await hub('GET', `/v1/repos/${repo}/standards`);
|
|
165
|
+
markUp(root);
|
|
166
|
+
if (status === 401) return unreachable(json.why ?? `Sign in to the hub again: ${SIGN_IN}.`);
|
|
167
|
+
if (status === 404 || status === 405 || !Array.isArray(json?.standards))
|
|
168
|
+
return { ok: true, standards: [], waivers: [] };
|
|
169
|
+
return {
|
|
170
|
+
ok: true,
|
|
171
|
+
version: json.version ?? null,
|
|
172
|
+
standards: json.standards,
|
|
173
|
+
waivers: Array.isArray(json.waivers) ? json.waivers : [],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
161
176
|
const { status, json } = await hub('POST', `/v1/repos/${repo}/${op}`, body);
|
|
162
177
|
markUp(root);
|
|
163
178
|
if (status === 401) return unreachable(json.why ?? `Sign in to the hub again: ${SIGN_IN}.`);
|
package/hooks/lib/checks.mjs
CHANGED
|
@@ -16,13 +16,14 @@ function directories(check, files, root) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* The first failure as { title, output }, or null. `actor` decides `owners`; the lead always may. A
|
|
19
|
+
* The first failure as { check, title, output }, or null. `actor` decides `owners`; the lead always may. A
|
|
20
20
|
* command that isn't installed skips the rest of the check, unless `ifMissing` is "fail", and is
|
|
21
21
|
* added to `skipped` as { check, command, where }, so a pass never hides a check that didn't run.
|
|
22
22
|
*/
|
|
23
23
|
function runCheck(check, files, root, actor, skipped) {
|
|
24
24
|
if (check.owners && !(actor.kind === 'lead' || check.owners.includes(actor.lane))) {
|
|
25
25
|
return {
|
|
26
|
+
check: check.name,
|
|
26
27
|
title: `The ${actor.label} changed ${check.name} files it does not own:`,
|
|
27
28
|
output: files.join('\n'),
|
|
28
29
|
};
|
|
@@ -39,6 +40,7 @@ function runCheck(check, files, root, actor, skipped) {
|
|
|
39
40
|
if (res.status === 127) {
|
|
40
41
|
if (check.ifMissing === 'fail')
|
|
41
42
|
return {
|
|
43
|
+
check: check.name,
|
|
42
44
|
title: `${check.name}: \`${command}\` is not installed${where}. Install it so the check can run.`,
|
|
43
45
|
};
|
|
44
46
|
skipped.push({ check: check.name, command, where: where.replace(/^ in /, '') || null });
|
|
@@ -46,10 +48,12 @@ function runCheck(check, files, root, actor, skipped) {
|
|
|
46
48
|
}
|
|
47
49
|
if (res.error || res.status === null)
|
|
48
50
|
return {
|
|
51
|
+
check: check.name,
|
|
49
52
|
title: `${check.name}: \`${command}\` did not finish${where} (${res.error?.code ?? res.signal}).`,
|
|
50
53
|
};
|
|
51
54
|
if (res.status !== 0)
|
|
52
55
|
return {
|
|
56
|
+
check: check.name,
|
|
53
57
|
title: `${check.name}: \`${command}\` fails${where}. Fix it:`,
|
|
54
58
|
output: res.stdout + res.stderr,
|
|
55
59
|
};
|
package/hooks/lib/config.mjs
CHANGED
|
@@ -96,20 +96,38 @@ export function withDefaults(c) {
|
|
|
96
96
|
tests: c.tests ?? [],
|
|
97
97
|
regression: { prepare: c.regression?.prepare ?? null },
|
|
98
98
|
branches: { protected: c.branches?.protected ?? ['main', 'master'] },
|
|
99
|
-
approvals: {
|
|
99
|
+
approvals: {
|
|
100
|
+
lead: c.approvals?.lead === true,
|
|
101
|
+
delegate: delegateRules(c.approvals?.delegate, c.protected ?? []),
|
|
102
|
+
},
|
|
100
103
|
harness: harnessSettings(c.harness),
|
|
101
104
|
};
|
|
102
105
|
}
|
|
103
106
|
|
|
107
|
+
/** WHO-1: the approvals the person keeps when the config says nothing: product, design, CI and the kit. */
|
|
108
|
+
export const PERSON_KEEPS = ['spec', 'design', 'ci', 'kit'];
|
|
109
|
+
|
|
104
110
|
/**
|
|
105
111
|
* The rules within which the lead approves on its own (`approvals.delegate`), or null when it may not:
|
|
106
|
-
* the protected approvals it may grant,
|
|
112
|
+
* the protected approvals it may grant, the dependency rule packages must meet, merges, and weakened
|
|
113
|
+
* tests on a lane's branch. WHO-1: with no `approvals.delegate`, the lead decides everything technical:
|
|
114
|
+
* every protected approval but the person's (PERSON_KEEPS), dependencies within the registry's
|
|
115
|
+
* defaults, merges and weakened tests. `false` keeps everything for the person.
|
|
107
116
|
*/
|
|
108
|
-
function delegateRules(
|
|
109
|
-
if (
|
|
117
|
+
function delegateRules(given, protectedPaths = []) {
|
|
118
|
+
if (given === false) return null;
|
|
119
|
+
const d = given ?? {
|
|
120
|
+
protected: [...new Set(protectedPaths.map((p) => p.approval))].filter(
|
|
121
|
+
(a) => a && !PERSON_KEEPS.includes(a),
|
|
122
|
+
),
|
|
123
|
+
merge: true,
|
|
124
|
+
tests: true,
|
|
125
|
+
dependencies: {},
|
|
126
|
+
};
|
|
110
127
|
return {
|
|
111
128
|
protected: d.protected ?? [],
|
|
112
129
|
merge: d.merge === true,
|
|
130
|
+
tests: d.tests === true,
|
|
113
131
|
dependencies: d.dependencies
|
|
114
132
|
? {
|
|
115
133
|
licences: d.dependencies.licences ?? [
|
|
@@ -149,6 +167,129 @@ const isRegExp = (v) => {
|
|
|
149
167
|
* security reviewer (ONB-7, spec 17). A project set up before (no `setup`, or a lower one) is told what
|
|
150
168
|
* re-running init adds.
|
|
151
169
|
*/
|
|
170
|
+
/**
|
|
171
|
+
* WHO-3: who decides each approval, as the Settings tab's Approvals rows: one per protected approval
|
|
172
|
+
* (but the kit, which is always the person's), dependencies with the registry's rules, merges and
|
|
173
|
+
* weakened tests. Each row: { key, value, default, about, choices?, section: 'Approvals' }.
|
|
174
|
+
*/
|
|
175
|
+
export function approvalSettings(config) {
|
|
176
|
+
const rules = config.approvals.delegate;
|
|
177
|
+
const who = (lead) => (lead ? 'lead' : 'person');
|
|
178
|
+
const names = [...new Set(config.protected.map((p) => p.approval))].filter((n) => n !== 'kit');
|
|
179
|
+
const fallback = delegateRules(undefined, config.protected) ?? {};
|
|
180
|
+
const deps = rules?.dependencies ?? delegateRules({ dependencies: {} }).dependencies;
|
|
181
|
+
const row = (key, value, dflt, about, choices) => ({
|
|
182
|
+
key,
|
|
183
|
+
value,
|
|
184
|
+
default: dflt,
|
|
185
|
+
about,
|
|
186
|
+
section: 'Approvals',
|
|
187
|
+
...(choices ? { choices } : {}),
|
|
188
|
+
});
|
|
189
|
+
return [
|
|
190
|
+
...names.map((n) =>
|
|
191
|
+
row(
|
|
192
|
+
`approvals.${n}`,
|
|
193
|
+
who(rules?.protected.includes(n)),
|
|
194
|
+
who(fallback.protected?.includes(n)),
|
|
195
|
+
`Who approves a lane's change to what's protected as "${n}"`,
|
|
196
|
+
['lead', 'person'],
|
|
197
|
+
),
|
|
198
|
+
),
|
|
199
|
+
row(
|
|
200
|
+
'approvals.dependencies',
|
|
201
|
+
who(Boolean(rules?.dependencies)),
|
|
202
|
+
'lead',
|
|
203
|
+
"Who approves a lane's new package: the lead within the registry's rules, or you",
|
|
204
|
+
['lead', 'person'],
|
|
205
|
+
),
|
|
206
|
+
row(
|
|
207
|
+
'approvals.dependencies.licences',
|
|
208
|
+
deps.licences.join(','),
|
|
209
|
+
'MIT,Apache-2.0,BSD-2-Clause,BSD-3-Clause,ISC',
|
|
210
|
+
'The licences a package the lead approves may have, comma-separated',
|
|
211
|
+
),
|
|
212
|
+
row(
|
|
213
|
+
'approvals.dependencies.minWeeklyDownloads',
|
|
214
|
+
deps.minWeeklyDownloads,
|
|
215
|
+
10_000,
|
|
216
|
+
'The weekly downloads a package the lead approves must have, at least',
|
|
217
|
+
),
|
|
218
|
+
row(
|
|
219
|
+
'approvals.dependencies.maxMonthsSinceRelease',
|
|
220
|
+
deps.maxMonthsSinceRelease,
|
|
221
|
+
18,
|
|
222
|
+
"Months since a package's last release, at most, for the lead to approve it",
|
|
223
|
+
),
|
|
224
|
+
row(
|
|
225
|
+
'approvals.dependencies.allowInstallScripts',
|
|
226
|
+
Array.isArray(deps.allowInstallScripts)
|
|
227
|
+
? deps.allowInstallScripts.join(',')
|
|
228
|
+
: deps.allowInstallScripts,
|
|
229
|
+
false,
|
|
230
|
+
'Install scripts a package the lead approves may run: true, false, or kinds (binary, build, other)',
|
|
231
|
+
),
|
|
232
|
+
row(
|
|
233
|
+
'approvals.merge',
|
|
234
|
+
who(Boolean(rules?.merge)),
|
|
235
|
+
'lead',
|
|
236
|
+
'Who merges a reviewed branch into the base: merging is not releasing',
|
|
237
|
+
['lead', 'person'],
|
|
238
|
+
),
|
|
239
|
+
row(
|
|
240
|
+
'approvals.tests',
|
|
241
|
+
who(Boolean(rules?.tests)),
|
|
242
|
+
'lead',
|
|
243
|
+
"Who approves a weakened test on a lane's branch, after review; on the lead's own branches it is always you",
|
|
244
|
+
['lead', 'person'],
|
|
245
|
+
),
|
|
246
|
+
];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** WHO-3: the raw config with one approval setting changed (a copy), the rules written out in full. */
|
|
250
|
+
export function withApprovalSetting(raw, config, key, text) {
|
|
251
|
+
const rules = config.approvals.delegate;
|
|
252
|
+
const deps = rules?.dependencies ?? null;
|
|
253
|
+
const delegate = {
|
|
254
|
+
protected: [...(rules?.protected ?? [])],
|
|
255
|
+
merge: Boolean(rules?.merge),
|
|
256
|
+
tests: Boolean(rules?.tests),
|
|
257
|
+
...(deps ? { dependencies: { ...deps } } : {}),
|
|
258
|
+
};
|
|
259
|
+
const lead = text === 'lead' ? true : text === 'person' ? false : null;
|
|
260
|
+
const rule = key.replace(/^approvals\./, '');
|
|
261
|
+
const depRule = rule.startsWith('dependencies.') ? rule.slice('dependencies.'.length) : null;
|
|
262
|
+
if (depRule) {
|
|
263
|
+
const base = delegate.dependencies ?? delegateRules({ dependencies: {} }).dependencies;
|
|
264
|
+
const list = String(text)
|
|
265
|
+
.split(',')
|
|
266
|
+
.map((x) => x.trim())
|
|
267
|
+
.filter(Boolean);
|
|
268
|
+
const value =
|
|
269
|
+
depRule === 'licences'
|
|
270
|
+
? list
|
|
271
|
+
: depRule === 'allowInstallScripts'
|
|
272
|
+
? text === 'true' || (text === 'false' ? false : list)
|
|
273
|
+
: /^\d+$/.test(text)
|
|
274
|
+
? Number(text)
|
|
275
|
+
: text;
|
|
276
|
+
delegate.dependencies = { ...base, [depRule]: value };
|
|
277
|
+
} else if (lead === null) return null;
|
|
278
|
+
else if (rule === 'dependencies')
|
|
279
|
+
lead
|
|
280
|
+
? (delegate.dependencies = deps ?? delegateRules({ dependencies: {} }).dependencies)
|
|
281
|
+
: delete delegate.dependencies;
|
|
282
|
+
else if (rule === 'merge' || rule === 'tests') delegate[rule] = lead;
|
|
283
|
+
else if (config.protected.some((p) => p.approval === rule) && rule !== 'kit')
|
|
284
|
+
delegate.protected = lead
|
|
285
|
+
? [...new Set([...delegate.protected, rule])]
|
|
286
|
+
: delegate.protected.filter((n) => n !== rule);
|
|
287
|
+
else return null;
|
|
288
|
+
const copy = structuredClone(raw);
|
|
289
|
+
copy.approvals = { ...(copy.approvals ?? {}), delegate };
|
|
290
|
+
return copy;
|
|
291
|
+
}
|
|
292
|
+
|
|
152
293
|
export const SETUP_VERSION = 3;
|
|
153
294
|
|
|
154
295
|
/** Every problem with a parsed config, as readable sentences; empty when it is valid. */
|
|
@@ -174,7 +315,7 @@ export function validate(c) {
|
|
|
174
315
|
);
|
|
175
316
|
// A brownfield project may have no specs yet: then there is no spec-check gate.
|
|
176
317
|
need(c.docs === undefined || isObj(c.docs), '"docs" must be an object');
|
|
177
|
-
for (const k of ['specs', 'principles', 'plan', 'decisions'])
|
|
318
|
+
for (const k of ['specs', 'principles', 'plan', 'decisions', 'standards'])
|
|
178
319
|
need(c.docs?.[k] === undefined || isStr(c.docs[k]), `"docs.${k}" must be a path`);
|
|
179
320
|
need(isObj(c.lead) && isList(c.lead?.paths), '"lead.paths" must be a list of globs');
|
|
180
321
|
need(
|
|
@@ -236,9 +377,11 @@ export function validate(c) {
|
|
|
236
377
|
const isCount = (v) => v === undefined || (Number.isInteger(v) && v >= 0);
|
|
237
378
|
need(
|
|
238
379
|
d === undefined ||
|
|
380
|
+
d === false ||
|
|
239
381
|
(isObj(d) &&
|
|
240
382
|
(d.protected === undefined || isList(d.protected)) &&
|
|
241
383
|
[undefined, true, false].includes(d.merge) &&
|
|
384
|
+
[undefined, true, false].includes(d.tests) &&
|
|
242
385
|
(dep === undefined ||
|
|
243
386
|
(isObj(dep) &&
|
|
244
387
|
(dep.licences === undefined || isList(dep.licences)) &&
|
|
@@ -247,7 +390,7 @@ export function validate(c) {
|
|
|
247
390
|
([undefined, true, false].includes(dep.allowInstallScripts) ||
|
|
248
391
|
(isList(dep.allowInstallScripts) &&
|
|
249
392
|
dep.allowInstallScripts.every((k) => ['binary', 'build', 'other'].includes(k))))))),
|
|
250
|
-
'"approvals.delegate" must be { protected?: [approval names], merge?: true|false, dependencies?: { licences?, minWeeklyDownloads?, maxMonthsSinceRelease?, allowInstallScripts?: true|false|["binary","build","other"] } }',
|
|
393
|
+
'"approvals.delegate" must be false, or { protected?: [approval names], merge?: true|false, tests?: true|false, dependencies?: { licences?, minWeeklyDownloads?, maxMonthsSinceRelease?, allowInstallScripts?: true|false|["binary","build","other"] } }',
|
|
251
394
|
);
|
|
252
395
|
need(
|
|
253
396
|
c.branches === undefined || isList(c.branches?.protected),
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Decisions owned by the session that raised them (docs/specs/23-the-team.md, OWN-1 to OWN-4). What the
|
|
2
|
+
// person settles comes from two logs code-kit already keeps, each record now carrying its session: the
|
|
3
|
+
// lead's questions (lib/questions.mjs) and the approvals a refusal asked for (lib/rules.mjs). This reads
|
|
4
|
+
// both as one list, each decision with its member, subject and context, so a session sees, asks and
|
|
5
|
+
// answers only its own, and the others only a count. A decision from before sessions were recorded
|
|
6
|
+
// belongs to no session and shows in any.
|
|
7
|
+
import { openQuestions } from './questions.mjs';
|
|
8
|
+
import { openRequests } from './rules.mjs';
|
|
9
|
+
import { memberOf, readSession, sessions, shortSession } from './sessions.mjs';
|
|
10
|
+
|
|
11
|
+
/** The stories that are done: a decision about one of them has lost its cause (OWN-4). */
|
|
12
|
+
const doneIn = (stories) =>
|
|
13
|
+
new Set((stories ?? []).filter((s) => s.state === 'done').map((s) => s.id));
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Every open decision, oldest first: [{ id, kind, session, member, subject, what, context, options,
|
|
17
|
+
* blocks, count, opened, source }]. `kind` is `answer` (a question for the person) or `approve`.
|
|
18
|
+
*/
|
|
19
|
+
export function openDecisions(root, { stories = [] } = {}, now = Date.now()) {
|
|
20
|
+
const done = doneIn(stories);
|
|
21
|
+
const byId = new Map(sessions(root, now).map((s) => [s.id, s]));
|
|
22
|
+
const member = (session) =>
|
|
23
|
+
session ? (memberOf(byId.get(session)) ?? shortSession(session)) : null;
|
|
24
|
+
const questions = openQuestions(root)
|
|
25
|
+
.filter((q) => !(q.story && done.has(q.story)))
|
|
26
|
+
.map((q) => ({
|
|
27
|
+
id: q.id,
|
|
28
|
+
kind: 'answer',
|
|
29
|
+
session: q.session ?? null,
|
|
30
|
+
member: member(q.session),
|
|
31
|
+
subject: q.story ?? null,
|
|
32
|
+
what: q.question,
|
|
33
|
+
context: q.why ?? null,
|
|
34
|
+
options: q.options ?? [],
|
|
35
|
+
blocks: Boolean(q.blocks),
|
|
36
|
+
count: q.count ?? 1,
|
|
37
|
+
opened: q.at,
|
|
38
|
+
source: q,
|
|
39
|
+
}));
|
|
40
|
+
const requests = openRequests(root, now).map((r) => ({
|
|
41
|
+
id: `R:${r.at}`,
|
|
42
|
+
kind: 'approve',
|
|
43
|
+
session: r.session ?? null,
|
|
44
|
+
member: member(r.session),
|
|
45
|
+
subject: r.lane ?? null,
|
|
46
|
+
what: r.what,
|
|
47
|
+
context: r.why ?? null,
|
|
48
|
+
options: [],
|
|
49
|
+
blocks: true,
|
|
50
|
+
count: 1,
|
|
51
|
+
opened: r.at,
|
|
52
|
+
source: r,
|
|
53
|
+
}));
|
|
54
|
+
return [...questions, ...requests].sort((a, b) => Date.parse(a.opened) - Date.parse(b.opened));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The sessions whose decisions `session` answers for: its own, and those whose task it took over. */
|
|
58
|
+
function ownedBy(root, session) {
|
|
59
|
+
const me = readSession(root, session);
|
|
60
|
+
return new Set([session, ...(me?.took ?? [])]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* OWN-1: the decisions `session` sees and answers ({ mine }), and the count waiting in each other live
|
|
65
|
+
* session ({ elsewhere: [{ session, member, count }] }). A decision of a session that has ended stays with
|
|
66
|
+
* it: it isn't handed to another session's lead.
|
|
67
|
+
*/
|
|
68
|
+
export function decisionsFor(root, session, context = {}, now = Date.now()) {
|
|
69
|
+
const all = openDecisions(root, context, now);
|
|
70
|
+
const own = ownedBy(root, session);
|
|
71
|
+
const live = new Set(
|
|
72
|
+
sessions(root, now)
|
|
73
|
+
.filter((s) => s.live)
|
|
74
|
+
.map((s) => s.id),
|
|
75
|
+
);
|
|
76
|
+
const mine = all.filter((d) => !d.session || own.has(d.session));
|
|
77
|
+
const counts = new Map();
|
|
78
|
+
for (const d of all)
|
|
79
|
+
if (d.session && !own.has(d.session) && live.has(d.session))
|
|
80
|
+
counts.set(d.session, (counts.get(d.session) ?? 0) + 1);
|
|
81
|
+
return {
|
|
82
|
+
mine,
|
|
83
|
+
elsewhere: [...counts].map(([s, count]) => ({
|
|
84
|
+
session: s,
|
|
85
|
+
member: all.find((d) => d.session === s)?.member ?? shortSession(s),
|
|
86
|
+
count,
|
|
87
|
+
})),
|
|
88
|
+
};
|
|
89
|
+
}
|