@warren-dean/code-kit 0.3.11 → 0.3.15
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 +27 -0
- package/README.md +38 -26
- package/bin/code-kit.mjs +165 -4
- package/hooks/guard-bash.mjs +50 -0
- package/hooks/lib/adapters/context-graph.mjs +16 -1
- package/hooks/lib/adapters/index.mjs +4 -1
- package/hooks/lib/config.mjs +3 -1
- package/hooks/lib/rules.mjs +102 -0
- package/hooks/lib/verify.mjs +19 -2
- 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.15",
|
|
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.15",
|
|
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,33 @@
|
|
|
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.15 (2026-10-06)
|
|
6
|
+
|
|
7
|
+
- **`verify` accepts your Context Graph ratifications:** a change to `.ctx/graph.ctx` whose commits all carry your `Ctx-Ratified-By` trailer passes without an approval-log entry. That's what `ctx ratify --commit` and `ctx drop --commit` write from your terminal, where code-kit's hooks don't run to log it. Agents still can't write that trailer, and a delegated trailer still needs the log.
|
|
8
|
+
|
|
9
|
+
## 0.3.14 (2026-10-06)
|
|
10
|
+
|
|
11
|
+
- **Context Graph's person acts:** with Context Graph in the project, the hooks refuse `ctx ratify … --commit` and `ctx drop` from every agent, matching Context Graph 0.2.11, which makes both the person's own acts. Only a real command counts; text that mentions one, such as an `echo` or documentation, isn't refused.
|
|
12
|
+
|
|
13
|
+
## 0.3.13 (2026-10-06)
|
|
14
|
+
|
|
15
|
+
The command-line groundwork for the coming code-kit mod (`docs/brief.md`), useful on its own:
|
|
16
|
+
|
|
17
|
+
- **Approval requests:** every refusal that a person's approval would allow is now recorded as a request, once per actor and command. `code-kit requests [--json]` lists the open requests and the approvals in force, with minutes left. A request closes when its approvals are given.
|
|
18
|
+
- **`code-kit approve … --via pane`** marks an approval as given in the code-kit pane.
|
|
19
|
+
- **`code-kit merge <branch> --person`:** the person merges a branch after `verify` passes, with no delegation needed.
|
|
20
|
+
- **Agents:** the hooks refuse `--via` and `--person` from every agent.
|
|
21
|
+
- **`code-kit verify --json`** prints the branch, its lane and the problems by group.
|
|
22
|
+
|
|
23
|
+
## 0.3.12 (2026-10-05)
|
|
24
|
+
|
|
25
|
+
- **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.
|
|
26
|
+
- It runs `code-kit verify` on the branch first, checks included, and merges nothing if verify fails or the branch doesn't merge cleanly.
|
|
27
|
+
- Lanes can never run it.
|
|
28
|
+
- Merging by hand on a protected branch stays blocked for everyone.
|
|
29
|
+
- **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.
|
|
30
|
+
- **`code-kit unowned`** no longer reports files that any actor may write, such as the lockfile.
|
|
31
|
+
|
|
5
32
|
## 0.3.11 (2026-10-04)
|
|
6
33
|
|
|
7
34
|
- **`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
|
@@ -305,11 +305,13 @@ code-kit unowned # tracked files nobody may write
|
|
|
305
305
|
code-kit diff --config .claude/code-kit.draft.json # what a draft changes
|
|
306
306
|
code-kit baseline [--write] # layer violations in the code as it is
|
|
307
307
|
code-kit graph [--depth N] # which folders import which
|
|
308
|
-
code-kit verify [--base ref] [--branch name] [--no-checks] # a branch's changes, for CI and review
|
|
308
|
+
code-kit verify [--base ref] [--branch name] [--no-checks] [--json] # a branch's changes, for CI and review
|
|
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
|
|
312
|
+
code-kit merge <branch> --delegated|--person [--into branch] # merge a branch that passes verify: the lead where approvals.delegate.merge is on, or the person
|
|
313
|
+
code-kit requests [--json] # approval requests the hooks refused, still open, and the approvals in force
|
|
314
|
+
code-kit approve <name>... --reason "…" [--lane name] [--delegated | --via pane] # record a person's approval from chat, or (--delegated) grant one within the delegated rules; a package name stands for its dep- approval
|
|
313
315
|
```
|
|
314
316
|
|
|
315
317
|
`verify` compares the branch with where it left `--base` (default `origin/main`, then `main`). A branch named `<lane>/…`, or given as `--branch`, is held to that lane. `--no-checks` leaves the project's checks to CI's own steps. `status` reads `docs.specs` and `docs.plan`.
|
|
@@ -318,27 +320,27 @@ code-kit approve <name>... --reason "…" [--lane name] [--delegated] # record
|
|
|
318
320
|
|
|
319
321
|
`.claude/code-kit.json`. Globs use `*` (one segment), `**` (any depth), `**/` (zero or more folders), `?` and `{a,b}`. Patterns under `shell` are regular expressions.
|
|
320
322
|
|
|
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"]`
|
|
323
|
+
| Field | Required | What it is |
|
|
324
|
+
| ------------------------------ | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
325
|
+
| `version` | yes | `1` |
|
|
326
|
+
| `docs.specs` | no | Folder of specs. With it, a lane needs a spec-check report before writing code on a branch |
|
|
327
|
+
| `docs.principles`, `docs.plan` | no | The engineering principles and the implementation plan |
|
|
328
|
+
| `lead.paths` | yes | What the main session writes. `.claude/**` is always added. |
|
|
329
|
+
| `lead.outside` | no | Paths outside the repository the lead may write, e.g. `../shared-lib/**`. Claude's memory and scratchpad are always included. |
|
|
330
|
+
| `anyActor` | no | Paths every writer may change (e.g. the lockfile). `.claude/state/**` is always included. |
|
|
331
|
+
| `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. |
|
|
332
|
+
| `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`. |
|
|
333
|
+
| `designGate` | no | `{ register, screens: [{ glob, not? }], howTo }`: screen files wait until the register (`{ "screens": [{ "file": … }] }`) lists them |
|
|
334
|
+
| `layers[]` | no | `{ name, paths, mayImport, denyPackages? }`: the first layer whose paths match a file owns it. A layer may always import itself. |
|
|
335
|
+
| `importAliases` | no | `{ "@app/domain": "packages/domain/src" }`: resolves non-relative imports to repository paths |
|
|
336
|
+
| `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 |
|
|
337
|
+
| `shell.restricted[]` | no | `{ pattern, lanes, why }`: only the lead and these lanes |
|
|
338
|
+
| `postEdit[]` | no | `{ files, exclude?, run }`: after each edit of a matching file. `{file}` is the file. Exit 127 (tool not installed) is skipped. |
|
|
339
|
+
| `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). |
|
|
340
|
+
| `adapters` | no | `{ "<adapter>": false }` switches off an adapter that would otherwise be detected. See [Adapters](#adapters). |
|
|
341
|
+
| `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). |
|
|
342
|
+
| `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). |
|
|
343
|
+
| `branches.protected` | no | Default `["main", "master"]` |
|
|
342
344
|
|
|
343
345
|
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
346
|
|
|
@@ -371,6 +373,12 @@ code-kit approve dep-dayjs --lane web --reason "Yes, add dayjs for ST-12"
|
|
|
371
373
|
- The lead may also run `shell.block` commands marked `person: true`, such as Context Graph's ratification trailer.
|
|
372
374
|
- It trusts the lead to act only on the person's own words. Leave it off when the lead runs without a person watching.
|
|
373
375
|
|
|
376
|
+
### Requests
|
|
377
|
+
|
|
378
|
+
Every refusal that a person's approval would allow is recorded as a request in `.claude/state/requests.jsonl` in the main checkout: who was refused, the approvals that would allow it, the command or file, and why. A request stays open until approvals for all its names are in force, or for 60 minutes. `code-kit requests` lists the open ones and the approvals in force, with minutes left, and the code-kit mod shows them above the prompt.
|
|
379
|
+
|
|
380
|
+
`--via pane` and `merge --person` are the person's own acts, which the code-kit mod uses for a press in its pane. The hooks refuse both from every agent.
|
|
381
|
+
|
|
374
382
|
### Delegated
|
|
375
383
|
|
|
376
384
|
For a lead that runs without a person watching, such as an autonomous team, `approvals.delegate` lets the lead approve some things on its own authority, within rules the person sets in the config:
|
|
@@ -388,6 +396,10 @@ code-kit approve date-fns --lane web --delegated --reason "Date formatting for S
|
|
|
388
396
|
- **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
397
|
- **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
398
|
- **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.
|
|
399
|
+
- **Merges:** with `"merge": true`, the lead merges a reviewed branch into a protected branch with `code-kit merge <branch> --delegated`.
|
|
400
|
+
- 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.
|
|
401
|
+
- The merge commit says it was verified and delegated.
|
|
402
|
+
- 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
403
|
|
|
392
404
|
## Adapters
|
|
393
405
|
|
|
@@ -399,9 +411,9 @@ Other tools share a project with code-kit and keep files of their own. An adapte
|
|
|
399
411
|
|
|
400
412
|
While the tool is detected, its additions are merged into the effective config, like the kit's own defaults. Run `code-kit adapters` to see them, or switch one off with `"adapters": { "<name>": false }`.
|
|
401
413
|
|
|
402
|
-
| Adapter | Detected by | Adds
|
|
403
|
-
| --------------- | ----------- |
|
|
404
|
-
| `context-graph` | `.ctx/` | `.ctx/**` for the lead. `.ctx/decisions.ctx` and `.ctx/cards.ctx` for any actor, since every agent records decisions and file cards. `graph.ctx` and `config.toml` are protected (approval `ctx`). No Claude actor may write a person's `Ctx-Ratified-By` trailer: ratifying is a person's act (with `approvals.lead`, the lead may add it when the person says so in chat). A delegated trailer, `Ctx-Ratified-By: <ratifier> (delegated)`, is the lead's alone, under Context Graph's `[delegate]` rules. |
|
|
414
|
+
| Adapter | Detected by | Adds |
|
|
415
|
+
| --------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
416
|
+
| `context-graph` | `.ctx/` | `.ctx/**` for the lead. `.ctx/decisions.ctx` and `.ctx/cards.ctx` for any actor, since every agent records decisions and file cards. `graph.ctx` and `config.toml` are protected (approval `ctx`). No Claude actor may write a person's `Ctx-Ratified-By` trailer: ratifying is a person's act (with `approvals.lead`, the lead may add it when the person says so in chat). A delegated trailer, `Ctx-Ratified-By: <ratifier> (delegated)`, is the lead's alone, under Context Graph's `[delegate]` rules. `verify` accepts a `graph.ctx` change whose commits carry a person's trailer (`ctx ratify --commit`, `ctx drop --commit`) without an approval-log entry. Agents can't run those commands. |
|
|
405
417
|
|
|
406
418
|
Its setup:
|
|
407
419
|
|
package/bin/code-kit.mjs
CHANGED
|
@@ -15,12 +15,17 @@
|
|
|
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|--person [--into branch] merge a branch that passes verify: the lead
|
|
19
|
+
// (approvals.delegate.merge) or the person (the code-kit pane)
|
|
20
|
+
// requests [--json] open approval requests and approvals in force
|
|
18
21
|
// record approvals a person gave in chat (with approvals.lead on, the lead runs it)
|
|
19
22
|
// --config <file> reads a draft (.claude/code-kit.draft.json) instead of .claude/code-kit.json.
|
|
20
23
|
import { execFileSync } from 'node:child_process';
|
|
21
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
22
|
-
import {
|
|
24
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { tmpdir } from 'node:os';
|
|
26
|
+
import { join, posix, resolve } from 'node:path';
|
|
23
27
|
import { BASELINE_FILE, loadBaseline } from '../hooks/lib/baseline.mjs';
|
|
28
|
+
import { matchesAny } from '../hooks/lib/glob.mjs';
|
|
24
29
|
import { CONFIG_FILE, effectiveConfig, validate } from '../hooks/lib/config.mjs';
|
|
25
30
|
import { diffConfigs, ownershipMoves } from '../hooks/lib/diff.mjs';
|
|
26
31
|
import { CODE, importsOf, layerOf, layerProblems, targetOf } from '../hooks/lib/layers.mjs';
|
|
@@ -31,8 +36,11 @@ import { buildStatus } from '../hooks/lib/plan.mjs';
|
|
|
31
36
|
import {
|
|
32
37
|
APPROVAL_MINUTES,
|
|
33
38
|
currentBranch,
|
|
39
|
+
PANE,
|
|
40
|
+
approvalsInForce,
|
|
34
41
|
delegatedMark,
|
|
35
42
|
grantApprovals,
|
|
43
|
+
openRequests,
|
|
36
44
|
ownerOf,
|
|
37
45
|
} from '../hooks/lib/rules.mjs';
|
|
38
46
|
import { DEPENDENCY_APPROVAL, dependencyApproval } from '../hooks/lib/dependencies.mjs';
|
|
@@ -61,7 +69,9 @@ const baseRef = option('--base');
|
|
|
61
69
|
const branchName = option('--branch');
|
|
62
70
|
const reason = option('--reason');
|
|
63
71
|
const laneName = option('--lane');
|
|
64
|
-
const
|
|
72
|
+
const intoName = option('--into');
|
|
73
|
+
const via = option('--via');
|
|
74
|
+
const FLAGS = ['--write', '--no-checks', '--json', '--delegated', '--person'];
|
|
65
75
|
const flag = (name) => args.includes(name);
|
|
66
76
|
const writeBaseline = flag('--write');
|
|
67
77
|
const [command, ...rest] = args.filter((a) => !FLAGS.includes(a));
|
|
@@ -105,7 +115,9 @@ function who(paths) {
|
|
|
105
115
|
function unowned() {
|
|
106
116
|
const { config } = load();
|
|
107
117
|
const files = tracked();
|
|
108
|
-
const nobody = files.filter(
|
|
118
|
+
const nobody = files.filter(
|
|
119
|
+
(p) => !ownerOf(p, config) && !p.startsWith('.claude/') && !matchesAny(p, config.anyActor),
|
|
120
|
+
);
|
|
109
121
|
nobody.forEach(out);
|
|
110
122
|
out(`${nobody.length} of ${files.length} tracked files have no owner.`);
|
|
111
123
|
}
|
|
@@ -245,6 +257,135 @@ const VERIFY_GROUPS = {
|
|
|
245
257
|
checks: 'Checks',
|
|
246
258
|
};
|
|
247
259
|
|
|
260
|
+
// The lead merges a reviewed branch into a protected branch on its own, only where the config delegates
|
|
261
|
+
// merges, and only when `verify` passes on that branch, checks included. A person merges with git.
|
|
262
|
+
// With --person, the person merges (the mod passes it for a press in its pane): the same verify first,
|
|
263
|
+
// with no delegation needed. The hooks refuse --person from every agent.
|
|
264
|
+
function merge(branch) {
|
|
265
|
+
const { config } = load();
|
|
266
|
+
const byPerson = flag('--person');
|
|
267
|
+
if (!flag('--delegated') && !byPerson)
|
|
268
|
+
die(
|
|
269
|
+
'code-kit merge needs --delegated (the lead, within the delegated rules) or --person (the person, as from the code-kit pane).',
|
|
270
|
+
);
|
|
271
|
+
if (flag('--delegated') && byPerson)
|
|
272
|
+
die("A merge is either the lead's (--delegated) or the person's (--person), not both.");
|
|
273
|
+
if (!byPerson && !config.approvals.delegate?.merge)
|
|
274
|
+
die(
|
|
275
|
+
'This project doesn\'t delegate merges to the lead ("approvals.delegate.merge" isn\'t true). A person merges after review.',
|
|
276
|
+
);
|
|
277
|
+
const into = intoName ?? config.branches.protected[0];
|
|
278
|
+
if (!config.branches.protected.includes(into))
|
|
279
|
+
die(`${into} isn't a protected branch; merge into it with git.`);
|
|
280
|
+
if (config.branches.protected.includes(branch)) die(`${branch} is itself a protected branch.`);
|
|
281
|
+
const root = resolve('.');
|
|
282
|
+
const g = (cwd, ...a) =>
|
|
283
|
+
execFileSync('git', ['-C', cwd, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
284
|
+
try {
|
|
285
|
+
g(root, 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`);
|
|
286
|
+
} catch {
|
|
287
|
+
die(`There is no branch ${branch}.`);
|
|
288
|
+
}
|
|
289
|
+
const checkedOut = (b) =>
|
|
290
|
+
g(root, 'worktree', 'list', '--porcelain')
|
|
291
|
+
.split('\n\n')
|
|
292
|
+
.map((w) => ({ path: w.match(/^worktree (.+)$/m)?.[1], ref: w.match(/^branch (.+)$/m)?.[1] }))
|
|
293
|
+
.find((w) => w.ref === `refs/heads/${b}`)?.path;
|
|
294
|
+
const clean = (dir, b) => {
|
|
295
|
+
if (g(dir, 'status', '--porcelain', '--untracked-files=no').trim())
|
|
296
|
+
fail(`${b} has uncommitted changes in ${dir}; commit or stash them first.`);
|
|
297
|
+
};
|
|
298
|
+
const temporary = [];
|
|
299
|
+
// die() exits at once, so every way out of here removes the temporary worktrees first.
|
|
300
|
+
const cleanup = () => {
|
|
301
|
+
for (const dir of temporary.splice(0)) {
|
|
302
|
+
try {
|
|
303
|
+
g(root, 'worktree', 'remove', '--force', dir);
|
|
304
|
+
} catch {
|
|
305
|
+
// already gone
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
const fail = (message) => {
|
|
310
|
+
cleanup();
|
|
311
|
+
die(message);
|
|
312
|
+
};
|
|
313
|
+
const worktree = (b, detach) => {
|
|
314
|
+
const dir = mkdtempSync(join(tmpdir(), 'code-kit-merge-'));
|
|
315
|
+
rmSync(dir, { recursive: true });
|
|
316
|
+
g(root, 'worktree', 'add', '--quiet', ...(detach ? ['--detach'] : []), dir, b);
|
|
317
|
+
temporary.push(dir);
|
|
318
|
+
return dir;
|
|
319
|
+
};
|
|
320
|
+
try {
|
|
321
|
+
// Verify where the branch is checked out (its dependencies are installed there), else in a
|
|
322
|
+
// temporary worktree, where checks that need an install fail rather than being skipped.
|
|
323
|
+
const verifyIn = checkedOut(branch) ?? worktree(branch, true);
|
|
324
|
+
clean(verifyIn, branch);
|
|
325
|
+
const lane = laneOfBranch(branch, config);
|
|
326
|
+
const { changed, found } = verifyBranch({
|
|
327
|
+
root: verifyIn,
|
|
328
|
+
base: into,
|
|
329
|
+
config,
|
|
330
|
+
lane,
|
|
331
|
+
checks: true,
|
|
332
|
+
});
|
|
333
|
+
const problems = Object.entries(VERIFY_GROUPS).flatMap(([key, title]) =>
|
|
334
|
+
found[key].map((p) => `${title}: ${p.split('\n').join('\n ')}`),
|
|
335
|
+
);
|
|
336
|
+
if (problems.length)
|
|
337
|
+
fail(
|
|
338
|
+
`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.`,
|
|
339
|
+
);
|
|
340
|
+
const mergeIn = checkedOut(into) ?? worktree(into, false);
|
|
341
|
+
clean(mergeIn, into);
|
|
342
|
+
try {
|
|
343
|
+
g(
|
|
344
|
+
mergeIn,
|
|
345
|
+
'merge',
|
|
346
|
+
'--no-ff',
|
|
347
|
+
'-m',
|
|
348
|
+
`Merge ${branch} into ${into}`,
|
|
349
|
+
'-m',
|
|
350
|
+
`Verified with code-kit verify (${changed.length} file(s)${lane ? `, held to the ${lane} lane` : ''}) and merged ${byPerson ? 'by the person' : 'by the lead within the delegated rules'}.`,
|
|
351
|
+
branch,
|
|
352
|
+
);
|
|
353
|
+
} catch (e) {
|
|
354
|
+
try {
|
|
355
|
+
g(mergeIn, 'merge', '--abort');
|
|
356
|
+
} catch {
|
|
357
|
+
// nothing to abort
|
|
358
|
+
}
|
|
359
|
+
fail(
|
|
360
|
+
`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()}`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
out(
|
|
364
|
+
`Merged ${branch} into ${into}: verify passed on ${changed.length} file(s)${lane ? `, held to the ${lane} lane` : ''}.`,
|
|
365
|
+
);
|
|
366
|
+
} finally {
|
|
367
|
+
cleanup();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Open approval requests and the approvals in force, for the person and the mod's band.
|
|
372
|
+
function requests() {
|
|
373
|
+
load();
|
|
374
|
+
const open = openRequests(resolve('.'));
|
|
375
|
+
const granted = approvalsInForce(resolve('.'));
|
|
376
|
+
if (flag('--json')) return out(JSON.stringify({ open, inForce: granted }, null, 2));
|
|
377
|
+
out(open.length ? `${open.length} approval request(s) open:` : 'No approval requests open.');
|
|
378
|
+
for (const r of open)
|
|
379
|
+
out(
|
|
380
|
+
` ${r.names.join(', ')} for ${r.lane ? `the ${r.lane} lane` : r.actor}: ${r.what} (${r.why})`,
|
|
381
|
+
);
|
|
382
|
+
out(granted.length ? `\n${granted.length} approval(s) in force:` : '\nNo approvals in force.');
|
|
383
|
+
for (const a of granted)
|
|
384
|
+
out(
|
|
385
|
+
` ${a.name} for ${a.lane ? `the ${a.lane} lane` : 'any agent'}, ${a.minutesLeft} min left: ${a.reason}`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
248
389
|
function verify() {
|
|
249
390
|
const { config } = load();
|
|
250
391
|
const from = base(config, true);
|
|
@@ -264,6 +405,18 @@ function verify() {
|
|
|
264
405
|
lane,
|
|
265
406
|
checks: !flag('--no-checks'),
|
|
266
407
|
});
|
|
408
|
+
if (flag('--json')) {
|
|
409
|
+
const count = Object.values(found).reduce((n, list) => n + list.length, 0);
|
|
410
|
+
out(
|
|
411
|
+
JSON.stringify(
|
|
412
|
+
{ base: from, branch, lane, changed: changed.length, found, problems: count },
|
|
413
|
+
null,
|
|
414
|
+
2,
|
|
415
|
+
),
|
|
416
|
+
);
|
|
417
|
+
if (count) process.exit(1);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
267
420
|
out(
|
|
268
421
|
`${changed.length} file(s) changed since ${from}${lane ? `, held to the ${lane} lane (branch ${branch})` : ''}.`,
|
|
269
422
|
);
|
|
@@ -417,6 +570,11 @@ async function approve(given) {
|
|
|
417
570
|
`Not an approval or package name: ${bad.join(', ')}. Use the name the refusal gave, e.g. kit or dep-zod, or the package's name.`,
|
|
418
571
|
);
|
|
419
572
|
const marks = {};
|
|
573
|
+
if (via !== undefined && via !== 'pane')
|
|
574
|
+
die('--via takes only "pane": the code-kit mod passes it for a press in its pane.');
|
|
575
|
+
if (via === 'pane' && delegated)
|
|
576
|
+
die("An approval is either the person's (--via pane) or the lead's (--delegated), not both.");
|
|
577
|
+
if (via === 'pane') for (const n of names) marks[n] = PANE;
|
|
420
578
|
if (delegated) {
|
|
421
579
|
const rules = config.approvals.delegate;
|
|
422
580
|
if (!rules)
|
|
@@ -456,6 +614,8 @@ async function approve(given) {
|
|
|
456
614
|
const commands = {
|
|
457
615
|
check,
|
|
458
616
|
approve: () => (rest.length ? approve(rest) : usage()),
|
|
617
|
+
merge: () => (rest.length === 1 ? merge(rest[0]) : usage()),
|
|
618
|
+
requests,
|
|
459
619
|
trace: () => (rest.length ? trace(rest) : usage()),
|
|
460
620
|
adapters,
|
|
461
621
|
next,
|
|
@@ -473,6 +633,7 @@ function usage() {
|
|
|
473
633
|
' | verify [--base ref] [--branch name] [--no-checks] | status [--base ref] [--json]\n' +
|
|
474
634
|
' | next [--base ref] [--json] | adapters | trace <path>... [--json]\n' +
|
|
475
635
|
' | approve <name>... --reason "…" [--lane name] [--config file]',
|
|
636
|
+
' | merge <branch> --delegated|--person [--into branch] | requests [--json]',
|
|
476
637
|
);
|
|
477
638
|
}
|
|
478
639
|
await (commands[command] ?? usage)();
|
package/hooks/guard-bash.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
dependencyGrants,
|
|
21
21
|
owns,
|
|
22
22
|
protectedEntry,
|
|
23
|
+
recordRequest,
|
|
23
24
|
sessionRoot,
|
|
24
25
|
} from './lib/rules.mjs';
|
|
25
26
|
|
|
@@ -45,9 +46,11 @@ for (const [re, why] of ALWAYS) {
|
|
|
45
46
|
}
|
|
46
47
|
const packages = addedPackages(cmd);
|
|
47
48
|
const approves = /\bcode-kit(\.mjs)?["']?\s+approve\b/.test(cmd);
|
|
49
|
+
const delegatedMerge = /\bcode-kit(\.mjs)?["']?\s+merge\b/.test(cmd);
|
|
48
50
|
if (error) {
|
|
49
51
|
if (/\bgit\s+commit\b/.test(cmd)) block(`Blocked: ${error}\nFix the config before committing.`);
|
|
50
52
|
if (approves) block(`Blocked: ${error}\nApprovals wait until the config is valid.`);
|
|
53
|
+
if (delegatedMerge) block(`Blocked: ${error}\nMerges wait until the config is valid.`);
|
|
51
54
|
if (packages.length)
|
|
52
55
|
block(`Blocked: ${error}\nNew dependencies wait until the config is valid.\nCommand: ${cmd}`);
|
|
53
56
|
process.exit(0);
|
|
@@ -56,6 +59,25 @@ if (error) {
|
|
|
56
59
|
const branches = config.branches.protected
|
|
57
60
|
.map((b) => b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
58
61
|
.join('|');
|
|
62
|
+
// The branch is read before the command runs, so a command that switches to a protected branch and then
|
|
63
|
+
// commits or merges in the same breath is judged by what it does, not by where it starts. Moving a
|
|
64
|
+
// protected branch directly (branch -f, update-ref) is a commit on it by another name.
|
|
65
|
+
const writesHistory = /\bgit\s+(commit|merge|cherry-pick|rebase|revert|am)\b/.test(cmd);
|
|
66
|
+
const switchesToProtected = new RegExp(
|
|
67
|
+
`\\bgit\\s+(?:switch|checkout)\\s+(?:-[^\\s]+\\s+)*(${branches})(?![\\w./-])(?!\\s+--)`,
|
|
68
|
+
).test(cmd);
|
|
69
|
+
const movesProtected =
|
|
70
|
+
new RegExp(
|
|
71
|
+
`\\bgit\\s+branch\\s+(?:\\S+\\s+)*(?:-f|--force|-M|-C)\\b.*\\b(${branches})(?![\\w./-])`,
|
|
72
|
+
).test(cmd) ||
|
|
73
|
+
new RegExp(
|
|
74
|
+
`\\bgit\\s+update-ref\\s+(?:-\\S+\\s+)*(?:refs/heads/)?(${branches})(?![\\w./-])`,
|
|
75
|
+
).test(cmd);
|
|
76
|
+
if ((switchesToProtected && writesHistory) || movesProtected) {
|
|
77
|
+
block(
|
|
78
|
+
`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}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
59
81
|
if (new RegExp(`\\bgit\\s+push\\b.*\\b(${branches})\\b`).test(cmd)) {
|
|
60
82
|
block(
|
|
61
83
|
`Blocked: push to your own branch; a person merges to ${config.branches.protected[0]} after review.\nCommand: ${cmd}`,
|
|
@@ -80,6 +102,19 @@ if (approves && actor.kind !== 'lead')
|
|
|
80
102
|
block(
|
|
81
103
|
`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
104
|
);
|
|
105
|
+
// The pane's acts are the person's: no agent passes --person or --via, whatever its other permissions.
|
|
106
|
+
if ((delegatedMerge && /\s--person\b/.test(cmd)) || (approves && /\s--via\b/.test(cmd)))
|
|
107
|
+
block(
|
|
108
|
+
`Blocked: merging or approving as the person is the person's own act, from the code-kit pane or their terminal. Ask them, and stop.\nCommand: ${cmd}`,
|
|
109
|
+
);
|
|
110
|
+
if (delegatedMerge && actor.kind !== 'lead')
|
|
111
|
+
block(
|
|
112
|
+
`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}`,
|
|
113
|
+
);
|
|
114
|
+
if (delegatedMerge && !config.approvals.delegate?.merge)
|
|
115
|
+
block(
|
|
116
|
+
`Blocked: this project doesn't delegate merges to the lead ("approvals.delegate.merge" isn't true). A person merges after review.\nCommand: ${cmd}`,
|
|
117
|
+
);
|
|
83
118
|
// The lead approves on its own only with --delegated, and only where the config delegates; the
|
|
84
119
|
// command itself refuses anything outside the rules.
|
|
85
120
|
const delegating = /\s--delegated\b/.test(cmd);
|
|
@@ -101,6 +136,13 @@ if (packages.length) {
|
|
|
101
136
|
(p) => !approval(sessionIn ?? project, dependencyApproval(p), actor),
|
|
102
137
|
);
|
|
103
138
|
if (unapproved.length) {
|
|
139
|
+
recordRequest(
|
|
140
|
+
sessionIn ?? project,
|
|
141
|
+
actor,
|
|
142
|
+
unapproved.map(dependencyApproval),
|
|
143
|
+
cmd,
|
|
144
|
+
"A new dependency needs a person's approval.",
|
|
145
|
+
);
|
|
104
146
|
const ask =
|
|
105
147
|
actor.kind === 'lead'
|
|
106
148
|
? 'Ask the person to approve it by running'
|
|
@@ -156,6 +198,13 @@ const writes =
|
|
|
156
198
|
cmd,
|
|
157
199
|
);
|
|
158
200
|
if (touchesKit && writes && actor.kind !== 'lead' && !approval(root, 'kit', actor)) {
|
|
201
|
+
recordRequest(
|
|
202
|
+
root,
|
|
203
|
+
actor,
|
|
204
|
+
'kit',
|
|
205
|
+
cmd,
|
|
206
|
+
"The .claude kit is changed only by the lead or with a person's approval.",
|
|
207
|
+
);
|
|
159
208
|
block(
|
|
160
209
|
`Blocked: the .claude kit is changed only by the lead or with a person's approval. Ask the lead to run\n${approvalHowTo('kit', actor, root, undefined, config)}`,
|
|
161
210
|
);
|
|
@@ -208,6 +257,7 @@ function recordApprovals() {
|
|
|
208
257
|
const granted = entry && approval(root, entry.approval, actor);
|
|
209
258
|
const byDependency = actor.kind === 'lane' && dependencyChange(actor, file, root, config);
|
|
210
259
|
if (entry && !granted && actor.kind !== 'lead' && !byDependency) {
|
|
260
|
+
recordRequest(root, actor, entry.approval, file, entry.why);
|
|
211
261
|
block(
|
|
212
262
|
`Blocked: ${file} is ${entry.why}; committing it needs a person's approval in force. Ask the lead to run\n${approvalHowTo(entry.approval, actor, root, undefined, config)}`,
|
|
213
263
|
);
|
|
@@ -7,9 +7,14 @@
|
|
|
7
7
|
// lead act on what the person says in chat (`approvals.lead`). A delegated ratification, signed
|
|
8
8
|
// `Ctx-Ratified-By: <ratifier> (delegated)`, is the lead's own act under the repository's [delegate]
|
|
9
9
|
// rules: only the lead may write that trailer, and Context Graph's gate checks the kinds it covers.
|
|
10
|
+
// Ratifying with `--commit`, and dropping a proposal, are the person's acts in ctx too, so no actor runs
|
|
11
|
+
// them; only a command in command position counts, so text that mentions one isn't refused.
|
|
10
12
|
import { existsSync } from 'node:fs';
|
|
11
13
|
import { join } from 'node:path';
|
|
12
14
|
|
|
15
|
+
const CTX = String.raw`(?:ctx|node\s+["']?[^\s"']*ctx\.mjs["']?|npx\s+(?:--yes\s+)?@warren-dean/context-graph(?:@\S+)?)`;
|
|
16
|
+
const PERSONS_ACT = String.raw`(?:^|[;&|\n])\s*${CTX}\s+(?:ratify\b[^;&|\n]*\s--commit\b|drop\b)`;
|
|
17
|
+
|
|
13
18
|
export default {
|
|
14
19
|
name: 'context-graph',
|
|
15
20
|
detect: (root) => existsSync(join(root, '.ctx')),
|
|
@@ -17,7 +22,13 @@ export default {
|
|
|
17
22
|
lead: ['.ctx/**'],
|
|
18
23
|
anyActor: ['.ctx/decisions.ctx', '.ctx/cards.ctx'],
|
|
19
24
|
protected: [
|
|
20
|
-
{
|
|
25
|
+
{
|
|
26
|
+
glob: '.ctx/graph.ctx',
|
|
27
|
+
approval: 'ctx',
|
|
28
|
+
why: "Context Graph's rules and concepts",
|
|
29
|
+
// A person's ratification (`ctx ratify --commit`, `ctx drop --commit`) is signed, not logged.
|
|
30
|
+
signedBy: '^Ctx-Ratified-By:(?![^\\n]*\\(delegated\\))\\s*\\S',
|
|
31
|
+
},
|
|
21
32
|
{ glob: '.ctx/config.toml', approval: 'ctx', why: "Context Graph's settings" },
|
|
22
33
|
],
|
|
23
34
|
},
|
|
@@ -28,6 +39,10 @@ export default {
|
|
|
28
39
|
why: "Ratifying a Context Graph rule is a person's act: they add the Ctx-Ratified-By trailer themselves, with a `!` git command.",
|
|
29
40
|
person: true,
|
|
30
41
|
},
|
|
42
|
+
{
|
|
43
|
+
pattern: PERSONS_ACT,
|
|
44
|
+
why: "Ratifying a Context Graph proposal with --commit, and dropping one, are the person's own acts: they run them themselves, with a `!` command, or from the Context Graph pane.",
|
|
45
|
+
},
|
|
31
46
|
],
|
|
32
47
|
restricted: [
|
|
33
48
|
{
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
//
|
|
4
4
|
// name the tool, and the key that switches it off in the config: "adapters": { "<name>": false }
|
|
5
5
|
// detect (root) => true when the tool is in use in this project
|
|
6
|
-
// config { lead?, anyActor?, protected? } merged into the effective config, like the kit's defaults
|
|
6
|
+
// config { lead?, anyActor?, protected? } merged into the effective config, like the kit's defaults.
|
|
7
|
+
// A protected entry may name `signedBy`, a pattern for a commit-message line that only a
|
|
8
|
+
// person may write (the hooks block it for every actor); verify accepts a change whose
|
|
9
|
+
// commits all carry it in place of an approval-log entry
|
|
7
10
|
// shell { block?, restricted? } block: commands refused for every Claude actor, merged into
|
|
8
11
|
// shell.block (`person: true` marks a person's act the lead may do for them under
|
|
9
12
|
// approvals.lead); restricted: commands only the lead and the listed lanes may run
|
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/hooks/lib/rules.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// sets `approvals.lead` (a person on a phone has no `!`), by saying so in chat for the lead to record
|
|
15
15
|
// with `code-kit approve`.
|
|
16
16
|
import {
|
|
17
|
+
appendFileSync,
|
|
17
18
|
existsSync,
|
|
18
19
|
mkdirSync,
|
|
19
20
|
readFileSync,
|
|
@@ -38,6 +39,10 @@ const NEVER_WRITABLE = [`${APPROVALS_DIR}/**`, APPROVAL_LOG];
|
|
|
38
39
|
export const CLI = fileURLToPath(new URL('../../bin/code-kit.mjs', import.meta.url));
|
|
39
40
|
// Marks an approval the lead recorded from chat, so the log shows how it was given.
|
|
40
41
|
export const RELAYED = '(given in chat, recorded by the lead)';
|
|
42
|
+
/** Marks an approval the person gave with a button in the code-kit pane (the mod). */
|
|
43
|
+
export const PANE = '(approved in the code-kit pane)';
|
|
44
|
+
/** Refusals a person's approval would allow, for the mod's band and `code-kit requests`. */
|
|
45
|
+
export const REQUESTS_FILE = '.claude/state/requests.jsonl';
|
|
41
46
|
|
|
42
47
|
/** The command the lead runs to record approvals a person gave in chat. */
|
|
43
48
|
/** Marks an approval the lead granted on its own, within `approvals.delegate`; `rule` names the rule. */
|
|
@@ -218,6 +223,102 @@ export function approval(root, name, actor) {
|
|
|
218
223
|
return lane ?? inForce(join(dir, name));
|
|
219
224
|
}
|
|
220
225
|
|
|
226
|
+
const requestsFile = (root) => join(projectWorktrees(root)[0] ?? root, REQUESTS_FILE);
|
|
227
|
+
const sameRequest = (a, b) =>
|
|
228
|
+
a.actor === b.actor &&
|
|
229
|
+
a.what === b.what &&
|
|
230
|
+
[...a.names].sort().join() === [...b.names].sort().join();
|
|
231
|
+
|
|
232
|
+
/** Every request recorded, oldest first; unreadable lines are skipped. */
|
|
233
|
+
export function readRequests(root) {
|
|
234
|
+
try {
|
|
235
|
+
return readFileSync(requestsFile(root), 'utf8')
|
|
236
|
+
.split('\n')
|
|
237
|
+
.filter(Boolean)
|
|
238
|
+
.flatMap((l) => {
|
|
239
|
+
try {
|
|
240
|
+
return [JSON.parse(l)];
|
|
241
|
+
} catch {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
} catch {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Records that `actor` was refused something the approvals `names` would allow, once: the same
|
|
252
|
+
* actor, names and command or file within the approval lifetime count as one request. Recording
|
|
253
|
+
* never changes the refusal, so any failure here is ignored.
|
|
254
|
+
*/
|
|
255
|
+
export function recordRequest(root, actor, names, what, why, now = Date.now()) {
|
|
256
|
+
try {
|
|
257
|
+
const request = {
|
|
258
|
+
at: new Date(now).toISOString(),
|
|
259
|
+
actor: actor?.label ?? 'unknown',
|
|
260
|
+
...(actor?.kind === 'lane' ? { lane: actor.lane } : {}),
|
|
261
|
+
names: [names].flat(),
|
|
262
|
+
what: String(what).slice(0, 200),
|
|
263
|
+
why: String(why).slice(0, 300),
|
|
264
|
+
};
|
|
265
|
+
const fresh = readRequests(root).filter(
|
|
266
|
+
(r) => now - Date.parse(r.at) < APPROVAL_MINUTES * 60_000,
|
|
267
|
+
);
|
|
268
|
+
if (fresh.some((r) => sameRequest(r, request))) return;
|
|
269
|
+
const file = requestsFile(root);
|
|
270
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
271
|
+
appendFileSync(file, `${JSON.stringify(request)}\n`);
|
|
272
|
+
} catch {
|
|
273
|
+
// the refusal stands either way
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Requests from the last 60 minutes that an approval in force doesn't yet answer, newest first. */
|
|
278
|
+
export function openRequests(root, now = Date.now()) {
|
|
279
|
+
const open = [];
|
|
280
|
+
for (const r of readRequests(root).reverse()) {
|
|
281
|
+
if (now - Date.parse(r.at) >= APPROVAL_MINUTES * 60_000) continue;
|
|
282
|
+
if (open.some((o) => sameRequest(o, r))) continue;
|
|
283
|
+
const actor = r.lane ? { kind: 'lane', lane: r.lane } : { kind: 'lead' };
|
|
284
|
+
if (r.names.every((n) => approval(root, n, actor))) continue;
|
|
285
|
+
open.push(r);
|
|
286
|
+
}
|
|
287
|
+
return open;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Approvals in force now: [{ name, lane?, reason, grantedAt, minutesLeft }]. */
|
|
291
|
+
export function approvalsInForce(root, now = Date.now()) {
|
|
292
|
+
const dir = approvalsDir(root);
|
|
293
|
+
const found = [];
|
|
294
|
+
const look = (folder, lane) => {
|
|
295
|
+
let names = [];
|
|
296
|
+
try {
|
|
297
|
+
names = readdirSync(folder);
|
|
298
|
+
} catch {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
for (const name of names) {
|
|
302
|
+
const file = join(folder, name);
|
|
303
|
+
if (statSync(file).isDirectory()) {
|
|
304
|
+
if (!lane) look(file, name);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const a = inForce(file);
|
|
308
|
+
if (!a) continue;
|
|
309
|
+
const left = APPROVAL_MINUTES - (now - Date.parse(a.grantedAt)) / 60_000;
|
|
310
|
+
found.push({
|
|
311
|
+
name,
|
|
312
|
+
...(lane ? { lane } : {}),
|
|
313
|
+
...a,
|
|
314
|
+
minutesLeft: Math.max(0, Math.floor(left)),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
look(dir);
|
|
319
|
+
return found;
|
|
320
|
+
}
|
|
321
|
+
|
|
221
322
|
/** The dependency approvals in force for `actor`: [{ name, reason, grantedAt }]. */
|
|
222
323
|
export function dependencyGrants(root, actor) {
|
|
223
324
|
if (actor?.kind === 'readonly') return [];
|
|
@@ -277,6 +378,7 @@ export function writeProblem(actor, rel, root, config) {
|
|
|
277
378
|
// The lead writes protected files without an approval: the person reviews them in the pull request,
|
|
278
379
|
// and every committed change is in the audit log. Anyone else needs a person's approval as well.
|
|
279
380
|
if (guarded && actor.kind !== 'lead' && !approval(root, guarded.approval, actor)) {
|
|
381
|
+
recordRequest(root, actor, guarded.approval, rel, guarded.why);
|
|
280
382
|
return `${rel} is ${guarded.why} and needs a person's approval. Ask the lead (a person) to run\n${approvalHowTo(guarded.approval, actor, root, undefined, config)}`;
|
|
281
383
|
}
|
|
282
384
|
if (actor.kind === 'readonly') return `${actor.label} is read-only; it may not write ${rel}.`;
|
package/hooks/lib/verify.mjs
CHANGED
|
@@ -80,6 +80,22 @@ function logEntries(root, base) {
|
|
|
80
80
|
return { entries, problems };
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Whether every commit since `from` that changed `file` carries the trailer a protected entry names as
|
|
85
|
+
* a person's signature (`signedBy`, a pattern for one line of the message). A person's own commit from
|
|
86
|
+
* their terminal never passes through the hooks, so for such files the signature stands in for the log.
|
|
87
|
+
*/
|
|
88
|
+
function signedByPerson(root, from, file, signedBy) {
|
|
89
|
+
if (!signedBy) return false;
|
|
90
|
+
const out = git(root, 'log', '--format=%B%x1e', `${from}..HEAD`, '--', file);
|
|
91
|
+
const messages = out
|
|
92
|
+
.split('\x1e')
|
|
93
|
+
.map((m) => m.trim())
|
|
94
|
+
.filter(Boolean);
|
|
95
|
+
const line = new RegExp(signedBy, 'm');
|
|
96
|
+
return messages.length > 0 && messages.every((m) => line.test(m));
|
|
97
|
+
}
|
|
98
|
+
|
|
83
99
|
/**
|
|
84
100
|
* Every problem with the branch, grouped: { ownership, protected, layers, baseline, design, checks }.
|
|
85
101
|
* `lane` holds the branch to that lane's paths; `checks: false` leaves the project's checks to CI's
|
|
@@ -100,7 +116,8 @@ export function verifyBranch({ root, base, config, lane = null, checks = true })
|
|
|
100
116
|
found.ownership.push(`${f} may hold secrets; secrets never go in the repository.`);
|
|
101
117
|
if (f === APPROVAL_LOG) continue;
|
|
102
118
|
const guarded = protectedEntry(f, config);
|
|
103
|
-
|
|
119
|
+
const signed = guarded && !logged.has(f) && signedByPerson(root, from, f, guarded.signedBy);
|
|
120
|
+
if (guarded && !logged.has(f) && !signed)
|
|
104
121
|
found.protected.push(
|
|
105
122
|
`${f} is ${guarded.why}, but no approval-log entry records this change. Protected files are committed through Claude Code with code-kit, which logs them.`,
|
|
106
123
|
);
|
|
@@ -114,7 +131,7 @@ export function verifyBranch({ root, base, config, lane = null, checks = true })
|
|
|
114
131
|
isDependencyFile(f) &&
|
|
115
132
|
entry?.approval === 'dependency' &&
|
|
116
133
|
[null, lane].includes(laneOf(f, config));
|
|
117
|
-
const approved = (guarded && entry?.approval === guarded.approval) || dependency;
|
|
134
|
+
const approved = (guarded && entry?.approval === guarded.approval) || signed || dependency;
|
|
118
135
|
if (!own && !approved)
|
|
119
136
|
found.ownership.push(
|
|
120
137
|
`${f}: the ${lane} lane doesn't own it (owner: ${ownerOf(f, config) ?? 'nobody'}).`,
|
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.15",
|
|
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",
|