@warren-dean/code-kit 0.3.11 → 0.3.12
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 +9 -0
- package/README.md +26 -21
- package/bin/code-kit.mjs +117 -3
- package/hooks/guard-bash.mjs +29 -0
- package/hooks/lib/config.mjs +3 -1
- package/package.json +1 -1
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"name": "code-kit",
|
|
10
10
|
"source": "./",
|
|
11
11
|
"description": "Keeps AI coding agents inside your project's rules, from idea to merged code: spec design, lanes, layer rules, protected paths with an approval log, spec-check and design gates, proof-before-finish checks, dispatch, review, build status and CI verification, configured per project from its docs",
|
|
12
|
-
"version": "0.3.
|
|
12
|
+
"version": "0.3.12",
|
|
13
13
|
"category": "development",
|
|
14
14
|
"tags": ["guardrails", "architecture", "lanes", "layers", "spec-driven", "ci", "multi-agent"],
|
|
15
15
|
"license": "MIT"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "code-kit",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
4
4
|
"description": "Keeps AI coding agents inside your project's rules, from idea to merged code: spec design, lanes, layer rules, protected paths with an approval log, spec-check and design gates, proof-before-finish checks, dispatch, review, build status and CI verification, configured per project from its docs",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Warren Langeveldt"
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
What changed in each release of code-kit, newest first. Versions follow the plugin manifest and the npm package `@warren-dean/code-kit`. Each release is also on [GitHub Releases](https://github.com/warrendeanlangeveldt/code-kit/releases).
|
|
4
4
|
|
|
5
|
+
## 0.3.12 (2026-10-05)
|
|
6
|
+
|
|
7
|
+
- **Delegated merges:** with `"approvals": { "delegate": { "merge": true } }`, the lead can merge a reviewed branch into a protected branch with `code-kit merge <branch> --delegated`. This is for an autonomous lead.
|
|
8
|
+
- It runs `code-kit verify` on the branch first, checks included, and merges nothing if verify fails or the branch doesn't merge cleanly.
|
|
9
|
+
- Lanes can never run it.
|
|
10
|
+
- Merging by hand on a protected branch stays blocked for everyone.
|
|
11
|
+
- **A hole closed:** a command that switches to a protected branch and then commits, merges, cherry-picks, rebases or reverts (`git switch main && git merge x`) is now refused. Before, the branch was read only before the command ran. Moving a protected branch directly (`git branch -f main`, `git update-ref refs/heads/main`) is refused too.
|
|
12
|
+
- **`code-kit unowned`** no longer reports files that any actor may write, such as the lockfile.
|
|
13
|
+
|
|
5
14
|
## 0.3.11 (2026-10-04)
|
|
6
15
|
|
|
7
16
|
- **`code-kit verify` and union-merged approval logs:** verify now accepts an approval log that git merged with `merge=union`, as the README recommends.
|
package/README.md
CHANGED
|
@@ -309,6 +309,7 @@ code-kit verify [--base ref] [--branch name] [--no-checks] # a branch's changes
|
|
|
309
309
|
code-kit status [--base ref] [--json] # stories and requirements against the spec
|
|
310
310
|
code-kit next [--base ref] [--json] # the step to take now, from the project's state
|
|
311
311
|
code-kit trace <path>... [--json] # what a file is for: requirements, lane, layer rules
|
|
312
|
+
code-kit merge <branch> --delegated [--into branch] # the lead merges a branch that passes verify, where approvals.delegate.merge is on
|
|
312
313
|
code-kit approve <name>... --reason "…" [--lane name] [--delegated] # record a person's approval from chat, or (--delegated) grant one within the delegated rules; a package name stands for its dep- approval
|
|
313
314
|
```
|
|
314
315
|
|
|
@@ -318,27 +319,27 @@ code-kit approve <name>... --reason "…" [--lane name] [--delegated] # record
|
|
|
318
319
|
|
|
319
320
|
`.claude/code-kit.json`. Globs use `*` (one segment), `**` (any depth), `**/` (zero or more folders), `?` and `{a,b}`. Patterns under `shell` are regular expressions.
|
|
320
321
|
|
|
321
|
-
| Field | Required | What it is
|
|
322
|
-
| ------------------------------ | ----------------- |
|
|
323
|
-
| `version` | yes | `1`
|
|
324
|
-
| `docs.specs` | no | Folder of specs. With it, a lane needs a spec-check report before writing code on a branch
|
|
325
|
-
| `docs.principles`, `docs.plan` | no | The engineering principles and the implementation plan
|
|
326
|
-
| `lead.paths` | yes | What the main session writes. `.claude/**` is always added.
|
|
327
|
-
| `lead.outside` | no | Paths outside the repository the lead may write, e.g. `../shared-lib/**`. Claude's memory and scratchpad are always included.
|
|
328
|
-
| `anyActor` | no | Paths every writer may change (e.g. the lockfile). `.claude/state/**` is always included.
|
|
329
|
-
| `lanes.<name>` | yes (may be `{}`) | `{ agent, paths, exclude? }`: the agent name maps a subagent to its lane. A worktree with a `.lane` file holding the lane name makes the main session that lane.
|
|
330
|
-
| `protected[]` | no | `{ glob, approval, why }`: logged on every commit. Anyone but the lead needs a person's approval (see [Approvals](#approvals)). `.claude/**` is always protected as `kit`.
|
|
331
|
-
| `designGate` | no | `{ register, screens: [{ glob, not? }], howTo }`: screen files wait until the register (`{ "screens": [{ "file": … }] }`) lists them
|
|
332
|
-
| `layers[]` | no | `{ name, paths, mayImport, denyPackages? }`: the first layer whose paths match a file owns it. A layer may always import itself.
|
|
333
|
-
| `importAliases` | no | `{ "@app/domain": "packages/domain/src" }`: resolves non-relative imports to repository paths
|
|
334
|
-
| `shell.block[]` | no | `{ pattern, why, person? }`: never run. `person: true` marks a person's act, which the lead may do for them with `approvals.lead` on
|
|
335
|
-
| `shell.restricted[]` | no | `{ pattern, lanes, why }`: only the lead and these lanes
|
|
336
|
-
| `postEdit[]` | no | `{ files, exclude?, run }`: after each edit of a matching file. `{file}` is the file. Exit 127 (tool not installed) is skipped.
|
|
337
|
-
| `checks[]` | no | `{ name, files, exclude?, run[], each?, owners?, ifMissing?, timeoutSeconds? }`: before finishing, when matching files changed. `each` runs the commands in every changed folder matching that glob. `owners` limits who may change the files (the lead always may). `ifMissing: "fail"` makes a missing tool an error (the default is to skip).
|
|
338
|
-
| `adapters` | no | `{ "<adapter>": false }` switches off an adapter that would otherwise be detected. See [Adapters](#adapters).
|
|
339
|
-
| `approvals.lead` | no | `true` lets the lead act on what the person says in chat, for when `!` isn't available (on a phone, say): it records their approvals with `code-kit approve`, and runs `shell.block` commands marked `person`. Default `false`. See [Approvals](#approvals).
|
|
340
|
-
| `approvals.delegate` | no | `{ protected?: [approval names], dependencies?: { licences?, minWeeklyDownloads?, maxMonthsSinceRelease?, allowInstallScripts? } }`: approvals the lead may grant on its own, with `code-kit approve --delegated`. Dependencies are checked against the npm registry; the defaults are MIT, Apache-2.0, BSD and ISC, 10,000 weekly downloads, a release within 18 months, and no install scripts. See [Approvals](#approvals). |
|
|
341
|
-
| `branches.protected` | no | Default `["main", "master"]`
|
|
322
|
+
| Field | Required | What it is |
|
|
323
|
+
| ------------------------------ | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
324
|
+
| `version` | yes | `1` |
|
|
325
|
+
| `docs.specs` | no | Folder of specs. With it, a lane needs a spec-check report before writing code on a branch |
|
|
326
|
+
| `docs.principles`, `docs.plan` | no | The engineering principles and the implementation plan |
|
|
327
|
+
| `lead.paths` | yes | What the main session writes. `.claude/**` is always added. |
|
|
328
|
+
| `lead.outside` | no | Paths outside the repository the lead may write, e.g. `../shared-lib/**`. Claude's memory and scratchpad are always included. |
|
|
329
|
+
| `anyActor` | no | Paths every writer may change (e.g. the lockfile). `.claude/state/**` is always included. |
|
|
330
|
+
| `lanes.<name>` | yes (may be `{}`) | `{ agent, paths, exclude? }`: the agent name maps a subagent to its lane. A worktree with a `.lane` file holding the lane name makes the main session that lane. |
|
|
331
|
+
| `protected[]` | no | `{ glob, approval, why }`: logged on every commit. Anyone but the lead needs a person's approval (see [Approvals](#approvals)). `.claude/**` is always protected as `kit`. |
|
|
332
|
+
| `designGate` | no | `{ register, screens: [{ glob, not? }], howTo }`: screen files wait until the register (`{ "screens": [{ "file": … }] }`) lists them |
|
|
333
|
+
| `layers[]` | no | `{ name, paths, mayImport, denyPackages? }`: the first layer whose paths match a file owns it. A layer may always import itself. |
|
|
334
|
+
| `importAliases` | no | `{ "@app/domain": "packages/domain/src" }`: resolves non-relative imports to repository paths |
|
|
335
|
+
| `shell.block[]` | no | `{ pattern, why, person? }`: never run. `person: true` marks a person's act, which the lead may do for them with `approvals.lead` on |
|
|
336
|
+
| `shell.restricted[]` | no | `{ pattern, lanes, why }`: only the lead and these lanes |
|
|
337
|
+
| `postEdit[]` | no | `{ files, exclude?, run }`: after each edit of a matching file. `{file}` is the file. Exit 127 (tool not installed) is skipped. |
|
|
338
|
+
| `checks[]` | no | `{ name, files, exclude?, run[], each?, owners?, ifMissing?, timeoutSeconds? }`: before finishing, when matching files changed. `each` runs the commands in every changed folder matching that glob. `owners` limits who may change the files (the lead always may). `ifMissing: "fail"` makes a missing tool an error (the default is to skip). |
|
|
339
|
+
| `adapters` | no | `{ "<adapter>": false }` switches off an adapter that would otherwise be detected. See [Adapters](#adapters). |
|
|
340
|
+
| `approvals.lead` | no | `true` lets the lead act on what the person says in chat, for when `!` isn't available (on a phone, say): it records their approvals with `code-kit approve`, and runs `shell.block` commands marked `person`. Default `false`. See [Approvals](#approvals). |
|
|
341
|
+
| `approvals.delegate` | no | `{ protected?: [approval names], merge?: true, dependencies?: { licences?, minWeeklyDownloads?, maxMonthsSinceRelease?, allowInstallScripts? } }`: approvals the lead may grant on its own, with `code-kit approve --delegated`, and with `merge`, merges into a protected branch with `code-kit merge --delegated`. Dependencies are checked against the npm registry; the defaults are MIT, Apache-2.0, BSD and ISC, 10,000 weekly downloads, a release within 18 months, and no install scripts. See [Approvals](#approvals). |
|
|
342
|
+
| `branches.protected` | no | Default `["main", "master"]` |
|
|
342
343
|
|
|
343
344
|
Add `.claude/approvals/`, `.claude/state/` and `.claude/code-kit.draft.json` to `.gitignore`, and `.claude/approval-log.jsonl merge=union` to `.gitattributes`, so merging branches that both logged changes keeps every entry. Commit `.claude/code-kit.baseline.json` if the project has one.
|
|
344
345
|
|
|
@@ -388,6 +389,10 @@ code-kit approve date-fns --lane web --delegated --reason "Date formatting for S
|
|
|
388
389
|
- **All or nothing:** if any name is outside the rules, nothing is approved, and the command says which rule each one broke, so the lead asks the person.
|
|
389
390
|
- **The record:** the approval reads `(approved by the lead within the delegated rules: <rule>)` after the reason. The approval log keeps it when the change is committed.
|
|
390
391
|
- **Who:** only the lead. A lane asks the lead with a change request, and its refusals say the lead may approve within the rules. Without `approvals.delegate`, `--delegated` is refused.
|
|
392
|
+
- **Merges:** with `"merge": true`, the lead merges a reviewed branch into a protected branch with `code-kit merge <branch> --delegated`.
|
|
393
|
+
- It runs `code-kit verify` on the branch first, checks included, and merges only if it passes. The checks run where the branch is checked out; in a temporary worktree without the project's install, checks that need one fail rather than being skipped.
|
|
394
|
+
- The merge commit says it was verified and delegated.
|
|
395
|
+
- Committing or merging on a protected branch with git stays blocked for every actor, the lead included, as does switching to one and committing in the same command.
|
|
391
396
|
|
|
392
397
|
## Adapters
|
|
393
398
|
|
package/bin/code-kit.mjs
CHANGED
|
@@ -15,12 +15,15 @@
|
|
|
15
15
|
// next [--base ref] [--json]
|
|
16
16
|
// the step to take now (spec-design, init, dispatch, review…), from the project's state
|
|
17
17
|
// approve <name>... --reason "…" [--lane name]
|
|
18
|
+
// merge <branch> --delegated [--into branch] the lead merges a branch that passes verify (approvals.delegate.merge)
|
|
18
19
|
// record approvals a person gave in chat (with approvals.lead on, the lead runs it)
|
|
19
20
|
// --config <file> reads a draft (.claude/code-kit.draft.json) instead of .claude/code-kit.json.
|
|
20
21
|
import { execFileSync } from 'node:child_process';
|
|
21
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
22
|
-
import {
|
|
22
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import { join, posix, resolve } from 'node:path';
|
|
23
25
|
import { BASELINE_FILE, loadBaseline } from '../hooks/lib/baseline.mjs';
|
|
26
|
+
import { matchesAny } from '../hooks/lib/glob.mjs';
|
|
24
27
|
import { CONFIG_FILE, effectiveConfig, validate } from '../hooks/lib/config.mjs';
|
|
25
28
|
import { diffConfigs, ownershipMoves } from '../hooks/lib/diff.mjs';
|
|
26
29
|
import { CODE, importsOf, layerOf, layerProblems, targetOf } from '../hooks/lib/layers.mjs';
|
|
@@ -61,6 +64,7 @@ const baseRef = option('--base');
|
|
|
61
64
|
const branchName = option('--branch');
|
|
62
65
|
const reason = option('--reason');
|
|
63
66
|
const laneName = option('--lane');
|
|
67
|
+
const intoName = option('--into');
|
|
64
68
|
const FLAGS = ['--write', '--no-checks', '--json', '--delegated'];
|
|
65
69
|
const flag = (name) => args.includes(name);
|
|
66
70
|
const writeBaseline = flag('--write');
|
|
@@ -105,7 +109,9 @@ function who(paths) {
|
|
|
105
109
|
function unowned() {
|
|
106
110
|
const { config } = load();
|
|
107
111
|
const files = tracked();
|
|
108
|
-
const nobody = files.filter(
|
|
112
|
+
const nobody = files.filter(
|
|
113
|
+
(p) => !ownerOf(p, config) && !p.startsWith('.claude/') && !matchesAny(p, config.anyActor),
|
|
114
|
+
);
|
|
109
115
|
nobody.forEach(out);
|
|
110
116
|
out(`${nobody.length} of ${files.length} tracked files have no owner.`);
|
|
111
117
|
}
|
|
@@ -245,6 +251,112 @@ const VERIFY_GROUPS = {
|
|
|
245
251
|
checks: 'Checks',
|
|
246
252
|
};
|
|
247
253
|
|
|
254
|
+
// The lead merges a reviewed branch into a protected branch on its own, only where the config delegates
|
|
255
|
+
// merges, and only when `verify` passes on that branch, checks included. A person merges with git.
|
|
256
|
+
function merge(branch) {
|
|
257
|
+
const { config } = load();
|
|
258
|
+
if (!flag('--delegated'))
|
|
259
|
+
die(
|
|
260
|
+
"code-kit merge is the lead's delegated merge: code-kit merge <branch> --delegated. A person merges with git as usual.",
|
|
261
|
+
);
|
|
262
|
+
if (!config.approvals.delegate?.merge)
|
|
263
|
+
die(
|
|
264
|
+
'This project doesn\'t delegate merges to the lead ("approvals.delegate.merge" isn\'t true). A person merges after review.',
|
|
265
|
+
);
|
|
266
|
+
const into = intoName ?? config.branches.protected[0];
|
|
267
|
+
if (!config.branches.protected.includes(into))
|
|
268
|
+
die(`${into} isn't a protected branch; merge into it with git.`);
|
|
269
|
+
if (config.branches.protected.includes(branch)) die(`${branch} is itself a protected branch.`);
|
|
270
|
+
const root = resolve('.');
|
|
271
|
+
const g = (cwd, ...a) =>
|
|
272
|
+
execFileSync('git', ['-C', cwd, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
273
|
+
try {
|
|
274
|
+
g(root, 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`);
|
|
275
|
+
} catch {
|
|
276
|
+
die(`There is no branch ${branch}.`);
|
|
277
|
+
}
|
|
278
|
+
const checkedOut = (b) =>
|
|
279
|
+
g(root, 'worktree', 'list', '--porcelain')
|
|
280
|
+
.split('\n\n')
|
|
281
|
+
.map((w) => ({ path: w.match(/^worktree (.+)$/m)?.[1], ref: w.match(/^branch (.+)$/m)?.[1] }))
|
|
282
|
+
.find((w) => w.ref === `refs/heads/${b}`)?.path;
|
|
283
|
+
const clean = (dir, b) => {
|
|
284
|
+
if (g(dir, 'status', '--porcelain', '--untracked-files=no').trim())
|
|
285
|
+
fail(`${b} has uncommitted changes in ${dir}; commit or stash them first.`);
|
|
286
|
+
};
|
|
287
|
+
const temporary = [];
|
|
288
|
+
// die() exits at once, so every way out of here removes the temporary worktrees first.
|
|
289
|
+
const cleanup = () => {
|
|
290
|
+
for (const dir of temporary.splice(0)) {
|
|
291
|
+
try {
|
|
292
|
+
g(root, 'worktree', 'remove', '--force', dir);
|
|
293
|
+
} catch {
|
|
294
|
+
// already gone
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
const fail = (message) => {
|
|
299
|
+
cleanup();
|
|
300
|
+
die(message);
|
|
301
|
+
};
|
|
302
|
+
const worktree = (b, detach) => {
|
|
303
|
+
const dir = mkdtempSync(join(tmpdir(), 'code-kit-merge-'));
|
|
304
|
+
rmSync(dir, { recursive: true });
|
|
305
|
+
g(root, 'worktree', 'add', '--quiet', ...(detach ? ['--detach'] : []), dir, b);
|
|
306
|
+
temporary.push(dir);
|
|
307
|
+
return dir;
|
|
308
|
+
};
|
|
309
|
+
try {
|
|
310
|
+
// Verify where the branch is checked out (its dependencies are installed there), else in a
|
|
311
|
+
// temporary worktree, where checks that need an install fail rather than being skipped.
|
|
312
|
+
const verifyIn = checkedOut(branch) ?? worktree(branch, true);
|
|
313
|
+
clean(verifyIn, branch);
|
|
314
|
+
const lane = laneOfBranch(branch, config);
|
|
315
|
+
const { changed, found } = verifyBranch({
|
|
316
|
+
root: verifyIn,
|
|
317
|
+
base: into,
|
|
318
|
+
config,
|
|
319
|
+
lane,
|
|
320
|
+
checks: true,
|
|
321
|
+
});
|
|
322
|
+
const problems = Object.entries(VERIFY_GROUPS).flatMap(([key, title]) =>
|
|
323
|
+
found[key].map((p) => `${title}: ${p.split('\n').join('\n ')}`),
|
|
324
|
+
);
|
|
325
|
+
if (problems.length)
|
|
326
|
+
fail(
|
|
327
|
+
`Nothing was merged. code-kit verify found ${problems.length} problem(s) on ${branch} (checked in ${verifyIn}):\n ${problems.join('\n ')}\nSend it back to its lane, or fix it, then merge again.`,
|
|
328
|
+
);
|
|
329
|
+
const mergeIn = checkedOut(into) ?? worktree(into, false);
|
|
330
|
+
clean(mergeIn, into);
|
|
331
|
+
try {
|
|
332
|
+
g(
|
|
333
|
+
mergeIn,
|
|
334
|
+
'merge',
|
|
335
|
+
'--no-ff',
|
|
336
|
+
'-m',
|
|
337
|
+
`Merge ${branch} into ${into}`,
|
|
338
|
+
'-m',
|
|
339
|
+
`Verified with code-kit verify (${changed.length} file(s)${lane ? `, held to the ${lane} lane` : ''}) and merged by the lead within the delegated rules.`,
|
|
340
|
+
branch,
|
|
341
|
+
);
|
|
342
|
+
} catch (e) {
|
|
343
|
+
try {
|
|
344
|
+
g(mergeIn, 'merge', '--abort');
|
|
345
|
+
} catch {
|
|
346
|
+
// nothing to abort
|
|
347
|
+
}
|
|
348
|
+
fail(
|
|
349
|
+
`Nothing was merged: ${branch} doesn't merge cleanly into ${into}. Merge ${into} into ${branch} and resolve it there, then merge again.\n${String(e.stderr ?? e.message).trim()}`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
out(
|
|
353
|
+
`Merged ${branch} into ${into}: verify passed on ${changed.length} file(s)${lane ? `, held to the ${lane} lane` : ''}.`,
|
|
354
|
+
);
|
|
355
|
+
} finally {
|
|
356
|
+
cleanup();
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
248
360
|
function verify() {
|
|
249
361
|
const { config } = load();
|
|
250
362
|
const from = base(config, true);
|
|
@@ -456,6 +568,7 @@ async function approve(given) {
|
|
|
456
568
|
const commands = {
|
|
457
569
|
check,
|
|
458
570
|
approve: () => (rest.length ? approve(rest) : usage()),
|
|
571
|
+
merge: () => (rest.length === 1 ? merge(rest[0]) : usage()),
|
|
459
572
|
trace: () => (rest.length ? trace(rest) : usage()),
|
|
460
573
|
adapters,
|
|
461
574
|
next,
|
|
@@ -473,6 +586,7 @@ function usage() {
|
|
|
473
586
|
' | verify [--base ref] [--branch name] [--no-checks] | status [--base ref] [--json]\n' +
|
|
474
587
|
' | next [--base ref] [--json] | adapters | trace <path>... [--json]\n' +
|
|
475
588
|
' | approve <name>... --reason "…" [--lane name] [--config file]',
|
|
589
|
+
' | merge <branch> --delegated [--into branch]',
|
|
476
590
|
);
|
|
477
591
|
}
|
|
478
592
|
await (commands[command] ?? usage)();
|
package/hooks/guard-bash.mjs
CHANGED
|
@@ -45,9 +45,11 @@ for (const [re, why] of ALWAYS) {
|
|
|
45
45
|
}
|
|
46
46
|
const packages = addedPackages(cmd);
|
|
47
47
|
const approves = /\bcode-kit(\.mjs)?["']?\s+approve\b/.test(cmd);
|
|
48
|
+
const delegatedMerge = /\bcode-kit(\.mjs)?["']?\s+merge\b/.test(cmd);
|
|
48
49
|
if (error) {
|
|
49
50
|
if (/\bgit\s+commit\b/.test(cmd)) block(`Blocked: ${error}\nFix the config before committing.`);
|
|
50
51
|
if (approves) block(`Blocked: ${error}\nApprovals wait until the config is valid.`);
|
|
52
|
+
if (delegatedMerge) block(`Blocked: ${error}\nMerges wait until the config is valid.`);
|
|
51
53
|
if (packages.length)
|
|
52
54
|
block(`Blocked: ${error}\nNew dependencies wait until the config is valid.\nCommand: ${cmd}`);
|
|
53
55
|
process.exit(0);
|
|
@@ -56,6 +58,25 @@ if (error) {
|
|
|
56
58
|
const branches = config.branches.protected
|
|
57
59
|
.map((b) => b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
58
60
|
.join('|');
|
|
61
|
+
// The branch is read before the command runs, so a command that switches to a protected branch and then
|
|
62
|
+
// commits or merges in the same breath is judged by what it does, not by where it starts. Moving a
|
|
63
|
+
// protected branch directly (branch -f, update-ref) is a commit on it by another name.
|
|
64
|
+
const writesHistory = /\bgit\s+(commit|merge|cherry-pick|rebase|revert|am)\b/.test(cmd);
|
|
65
|
+
const switchesToProtected = new RegExp(
|
|
66
|
+
`\\bgit\\s+(?:switch|checkout)\\s+(?:-[^\\s]+\\s+)*(${branches})(?![\\w./-])(?!\\s+--)`,
|
|
67
|
+
).test(cmd);
|
|
68
|
+
const movesProtected =
|
|
69
|
+
new RegExp(
|
|
70
|
+
`\\bgit\\s+branch\\s+(?:\\S+\\s+)*(?:-f|--force|-M|-C)\\b.*\\b(${branches})(?![\\w./-])`,
|
|
71
|
+
).test(cmd) ||
|
|
72
|
+
new RegExp(
|
|
73
|
+
`\\bgit\\s+update-ref\\s+(?:-\\S+\\s+)*(?:refs/heads/)?(${branches})(?![\\w./-])`,
|
|
74
|
+
).test(cmd);
|
|
75
|
+
if ((switchesToProtected && writesHistory) || movesProtected) {
|
|
76
|
+
block(
|
|
77
|
+
`Blocked: never commit to, merge into or move ${config.branches.protected.join(' or ')}, even by switching to it first. Work on a branch; a person merges after review${config.approvals.delegate?.merge ? ', or the lead merges a verified branch with `code-kit merge <branch> --delegated`' : ''}.\nCommand: ${cmd}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
59
80
|
if (new RegExp(`\\bgit\\s+push\\b.*\\b(${branches})\\b`).test(cmd)) {
|
|
60
81
|
block(
|
|
61
82
|
`Blocked: push to your own branch; a person merges to ${config.branches.protected[0]} after review.\nCommand: ${cmd}`,
|
|
@@ -80,6 +101,14 @@ if (approves && actor.kind !== 'lead')
|
|
|
80
101
|
block(
|
|
81
102
|
`Blocked: only the lead records or grants approvals. Write a short change request for the lead (what needs approving, and why), and stop.\nCommand: ${cmd}`,
|
|
82
103
|
);
|
|
104
|
+
if (delegatedMerge && actor.kind !== 'lead')
|
|
105
|
+
block(
|
|
106
|
+
`Blocked: only the lead merges into a protected branch. Finish your story and commit it on your branch; the lead reviews and merges it.\nCommand: ${cmd}`,
|
|
107
|
+
);
|
|
108
|
+
if (delegatedMerge && !config.approvals.delegate?.merge)
|
|
109
|
+
block(
|
|
110
|
+
`Blocked: this project doesn't delegate merges to the lead ("approvals.delegate.merge" isn't true). A person merges after review.\nCommand: ${cmd}`,
|
|
111
|
+
);
|
|
83
112
|
// The lead approves on its own only with --delegated, and only where the config delegates; the
|
|
84
113
|
// command itself refuses anything outside the rules.
|
|
85
114
|
const delegating = /\s--delegated\b/.test(cmd);
|
package/hooks/lib/config.mjs
CHANGED
|
@@ -59,6 +59,7 @@ function delegateRules(d) {
|
|
|
59
59
|
if (!d) return null;
|
|
60
60
|
return {
|
|
61
61
|
protected: d.protected ?? [],
|
|
62
|
+
merge: d.merge === true,
|
|
62
63
|
dependencies: d.dependencies
|
|
63
64
|
? {
|
|
64
65
|
licences: d.dependencies.licences ?? [
|
|
@@ -152,13 +153,14 @@ export function validate(c) {
|
|
|
152
153
|
d === undefined ||
|
|
153
154
|
(isObj(d) &&
|
|
154
155
|
(d.protected === undefined || isList(d.protected)) &&
|
|
156
|
+
[undefined, true, false].includes(d.merge) &&
|
|
155
157
|
(dep === undefined ||
|
|
156
158
|
(isObj(dep) &&
|
|
157
159
|
(dep.licences === undefined || isList(dep.licences)) &&
|
|
158
160
|
isCount(dep.minWeeklyDownloads) &&
|
|
159
161
|
isCount(dep.maxMonthsSinceRelease) &&
|
|
160
162
|
[undefined, true, false].includes(dep.allowInstallScripts)))),
|
|
161
|
-
'"approvals.delegate" must be { protected?: [approval names], dependencies?: { licences?, minWeeklyDownloads?, maxMonthsSinceRelease?, allowInstallScripts? } }',
|
|
163
|
+
'"approvals.delegate" must be { protected?: [approval names], merge?: true|false, dependencies?: { licences?, minWeeklyDownloads?, maxMonthsSinceRelease?, allowInstallScripts? } }',
|
|
162
164
|
);
|
|
163
165
|
need(
|
|
164
166
|
c.branches === undefined || isList(c.branches?.protected),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@warren-dean/code-kit",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
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",
|