@warren-dean/code-kit 0.4.1 → 0.5.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 +18 -0
- package/README.md +26 -25
- package/bin/code-kit.mjs +69 -14
- package/hooks/guard-bash.mjs +79 -29
- package/hooks/lane-audit.mjs +17 -6
- package/hooks/lib/config.mjs +30 -5
- package/hooks/lib/diff.mjs +5 -1
- package/hooks/lib/next.mjs +13 -2
- package/hooks/lib/plan.mjs +18 -7
- package/hooks/lib/registry.mjs +37 -3
- package/hooks/lib/reviews.mjs +39 -0
- package/hooks/lib/rules.mjs +66 -4
- package/hooks/lib/shell.mjs +94 -0
- package/hooks/lib/trace.mjs +4 -2
- package/hooks/lib/verify.mjs +74 -3
- package/hooks/mod/register.test.ts +5 -3
- package/hooks/mod/view.mjs +13 -4
- package/package.json +1 -1
- package/skills/init/SKILL.md +10 -2
- package/skills/review/SKILL.md +9 -1
- package/templates/agent.md +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.
|
|
12
|
+
"version": "0.5.0",
|
|
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
|
+
"version": "0.5.0",
|
|
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,24 @@
|
|
|
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.5.0 (2026-10-07)
|
|
6
|
+
|
|
7
|
+
Fixes for gaps found running code-kit under an autonomous lead:
|
|
8
|
+
|
|
9
|
+
- **The Bash guard recognises commands where they run.** A commit message, a heredoc, a quoted `--text` or a grep pattern that mentions `git commit`, `code-kit merge` or switching to `main` no longer trips its rule. Text a command carries, such as a commit trailer or an approval path, is still read whole.
|
|
10
|
+
- **A dependency approval lasts until its install is committed,** at most 7 days, instead of 60 minutes, so a story that resumes later isn't refused again. The commit that adds the package uses it up. Other approvals still last 60 minutes.
|
|
11
|
+
- **status and next after a send-back:**
|
|
12
|
+
- `code-kit sent-back <branch>` records the commit a review sent back, and the review skill runs it;
|
|
13
|
+
- the story reads as "sent back", and `next` proposes the lane's fix instead of another review, until the branch moves on;
|
|
14
|
+
- a story whose dependency isn't done is blocked on it, even with commits on its branch.
|
|
15
|
+
- **verify holds a `lead/…` branch to the lead's paths** for what its own commits change. Lane work merged in after review isn't counted against it.
|
|
16
|
+
- **A lane merging its base** no longer needs fresh approvals for what the base already logged, and the ownership audit doesn't ask it to revert the base's changes.
|
|
17
|
+
- **A delegated merge follows the target branch's config,** so a branch can't grant itself a merge.
|
|
18
|
+
- **init's update keeps project skills in step with the docs' recipes:** a new skill per new recipe, updates, removals, and each skill listed on every lane agent whose paths it touches.
|
|
19
|
+
- **verify scans the branch's whole history for secrets:** a secret file committed and deleted again still fails, and gitleaks scans every commit when it's installed.
|
|
20
|
+
- **Install scripts are told apart** in the delegated dependency rule: `allowInstallScripts` takes `true`, `false` or the kinds allowed: `binary` (prebuilt binaries, as esbuild and sharp fetch), `build` or `other`.
|
|
21
|
+
- **Files any agent may write have a reviewer:** the lead, or a lane named in `{ "glob", "reviewer" }`. `who` and `trace` name it.
|
|
22
|
+
|
|
5
23
|
## 0.4.1 (2026-10-07)
|
|
6
24
|
|
|
7
25
|
- **The band keeps other plugins' lines:** code-kit's lines go above whatever else the band holds, such as Context Graph's "N proposals to ratify", instead of replacing it.
|
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ code-kit stops each of these at the moment it would happen. The hook refuses the
|
|
|
65
65
|
- a `<lane>/…` branch stays in that lane's paths, apart from manifest and lockfile changes logged under a dependency approval;
|
|
66
66
|
- every protected change is in the approval log, and the log is append-only;
|
|
67
67
|
- no new layer violations, and the baseline doesn't grow;
|
|
68
|
-
- no screens without an approved design, and no committed
|
|
68
|
+
- no screens without an approved design, and no secrets anywhere in the branch's history, including ones committed and deleted again;
|
|
69
69
|
- the project's checks pass.
|
|
70
70
|
|
|
71
71
|
A project without `.claude/code-kit.json` isn't governed at all. An invalid config fails closed: nothing but the config itself can be written until it's fixed.
|
|
@@ -107,7 +107,7 @@ Set it up at the start of a new build, straight from the architecture docs, or b
|
|
|
107
107
|
|
|
108
108
|
## Install
|
|
109
109
|
|
|
110
|
-
**Requirements:** Claude Code, Node 22 or later, and git. [gitleaks](https://github.com/gitleaks/gitleaks) is optional: when it's installed, every commit gets a secret scan.
|
|
110
|
+
**Requirements:** Claude Code, Node 22 or later, and git. [gitleaks](https://github.com/gitleaks/gitleaks) is optional: when it's installed, every commit gets a secret scan, and `verify` scans every commit on the branch.
|
|
111
111
|
|
|
112
112
|
**The plugin**, from this repository's marketplace:
|
|
113
113
|
|
|
@@ -330,6 +330,7 @@ code-kit trace <path>... [--json] # what a file is for: requirements, lane,
|
|
|
330
330
|
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
|
|
331
331
|
code-kit requests [--json] # approval requests the hooks refused, still open, and the approvals in force
|
|
332
332
|
code-kit stops [--session id] [--json] # finish checks that refused an agent's last stop and still fail
|
|
333
|
+
code-kit sent-back <branch> [--reason "…"] # record a review's send-back: status shows the story as sent back until the branch moves on
|
|
333
334
|
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
|
|
334
335
|
```
|
|
335
336
|
|
|
@@ -339,27 +340,27 @@ code-kit approve <name>... --reason "…" [--lane name] [--delegated | --via pan
|
|
|
339
340
|
|
|
340
341
|
`.claude/code-kit.json`. Globs use `*` (one segment), `**` (any depth), `**/` (zero or more folders), `?` and `{a,b}`. Patterns under `shell` are regular expressions.
|
|
341
342
|
|
|
342
|
-
| Field | Required | What it is
|
|
343
|
-
| ------------------------------ | ----------------- |
|
|
344
|
-
| `version` | yes | `1`
|
|
345
|
-
| `docs.specs` | no | Folder of specs. With it, a lane needs a spec-check report before writing code on a branch
|
|
346
|
-
| `docs.principles`, `docs.plan` | no | The engineering principles and the implementation plan
|
|
347
|
-
| `lead.paths` | yes | What the main session writes. `.claude/**` is always added.
|
|
348
|
-
| `lead.outside` | no | Paths outside the repository the lead may write, e.g. `../shared-lib/**`. Claude's memory and scratchpad are always included.
|
|
349
|
-
| `anyActor` | no | Paths every writer may change (e.g. the lockfile). `.claude/state/**` is always included.
|
|
350
|
-
| `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.
|
|
351
|
-
| `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`.
|
|
352
|
-
| `designGate` | no | `{ register, screens: [{ glob, not? }], howTo }`: screen files wait until the register (`{ "screens": [{ "file": … }] }`) lists them
|
|
353
|
-
| `layers[]` | no | `{ name, paths, mayImport, denyPackages? }`: the first layer whose paths match a file owns it. A layer may always import itself.
|
|
354
|
-
| `importAliases` | no | `{ "@app/domain": "packages/domain/src" }`: resolves non-relative imports to repository paths
|
|
355
|
-
| `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
|
|
356
|
-
| `shell.restricted[]` | no | `{ pattern, lanes, why }`: only the lead and these lanes
|
|
357
|
-
| `postEdit[]` | no | `{ files, exclude?, run }`: after each edit of a matching file. `{file}` is the file. Exit 127 (tool not installed) is skipped.
|
|
358
|
-
| `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).
|
|
359
|
-
| `adapters` | no | `{ "<adapter>": false }` switches off an adapter that would otherwise be detected. See [Adapters](#adapters).
|
|
360
|
-
| `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).
|
|
361
|
-
| `approvals.delegate` | no | `{ protected?: [approval names], merge?: true, dependencies?: { licences?, minWeeklyDownloads?, maxMonthsSinceRelease?, allowInstallScripts
|
|
362
|
-
| `branches.protected` | no | Default `["main", "master"]`
|
|
343
|
+
| Field | Required | What it is |
|
|
344
|
+
| ------------------------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
345
|
+
| `version` | yes | `1` |
|
|
346
|
+
| `docs.specs` | no | Folder of specs. With it, a lane needs a spec-check report before writing code on a branch |
|
|
347
|
+
| `docs.principles`, `docs.plan` | no | The engineering principles and the implementation plan |
|
|
348
|
+
| `lead.paths` | yes | What the main session writes. `.claude/**` is always added. |
|
|
349
|
+
| `lead.outside` | no | Paths outside the repository the lead may write, e.g. `../shared-lib/**`. Claude's memory and scratchpad are always included. |
|
|
350
|
+
| `anyActor` | no | Paths every writer may change (e.g. the lockfile). `.claude/state/**` is always included. An entry may be `{ "glob", "reviewer" }` to name the lane (or `"lead"`) that reviews changes to it; otherwise the lead reviews them, and `who` and `trace` say so. |
|
|
351
|
+
| `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. |
|
|
352
|
+
| `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`. |
|
|
353
|
+
| `designGate` | no | `{ register, screens: [{ glob, not? }], howTo }`: screen files wait until the register (`{ "screens": [{ "file": … }] }`) lists them |
|
|
354
|
+
| `layers[]` | no | `{ name, paths, mayImport, denyPackages? }`: the first layer whose paths match a file owns it. A layer may always import itself. |
|
|
355
|
+
| `importAliases` | no | `{ "@app/domain": "packages/domain/src" }`: resolves non-relative imports to repository paths |
|
|
356
|
+
| `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 |
|
|
357
|
+
| `shell.restricted[]` | no | `{ pattern, lanes, why }`: only the lead and these lanes |
|
|
358
|
+
| `postEdit[]` | no | `{ files, exclude?, run }`: after each edit of a matching file. `{file}` is the file. Exit 127 (tool not installed) is skipped. |
|
|
359
|
+
| `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). |
|
|
360
|
+
| `adapters` | no | `{ "<adapter>": false }` switches off an adapter that would otherwise be detected. See [Adapters](#adapters). |
|
|
361
|
+
| `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). |
|
|
362
|
+
| `approvals.delegate` | no | `{ protected?: [approval names], merge?: true, dependencies?: { licences?, minWeeklyDownloads?, maxMonthsSinceRelease?, allowInstallScripts?: true \| false \| [kinds] } }`: 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). |
|
|
363
|
+
| `branches.protected` | no | Default `["main", "master"]` |
|
|
363
364
|
|
|
364
365
|
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.
|
|
365
366
|
|
|
@@ -376,7 +377,7 @@ echo "Date formatting for ST-12" > .claude/approvals/web/dep-dayjs #
|
|
|
376
377
|
- **Scope:** an approval in a lane's folder serves that lane only; one directly in `.claude/approvals/` serves every agent. The refusal a lane gets names its own folder.
|
|
377
378
|
- **Dependencies:** each new package needs its own approval, `dep-<package>` (`/` becomes `+`, so `@scope/pkg` is `dep-@scope+pkg`). It allows the install, and the changes the install makes to manifests and lockfiles that no other lane owns. When the lane commits a manifest or lockfile outside its own paths, the commit hook logs it as a `dependency` approval, with the packages and reasons, and `code-kit verify` accepts it.
|
|
378
379
|
- **Worktrees:** approvals live in the main checkout, so a lane working in its own worktree sees the ones given at the lead's terminal.
|
|
379
|
-
- **Lifetime:** 60 minutes. Agents can't create, edit or even mention approvals or the log.
|
|
380
|
+
- **Lifetime:** 60 minutes; a dependency approval (`dep-…`) lasts until the lane commits the install it allows, at most 7 days, since a story may resume hours later. Agents can't create, edit or even mention approvals or the log.
|
|
380
381
|
|
|
381
382
|
### From chat
|
|
382
383
|
|
|
@@ -411,7 +412,7 @@ code-kit approve date-fns --lane web --delegated --reason "Date formatting for S
|
|
|
411
412
|
```
|
|
412
413
|
|
|
413
414
|
- **Protected approvals:** the lead may grant those named in `protected`, and no others.
|
|
414
|
-
- **Dependencies:** each package is checked against the npm registry. Its licence must be in `licences`; it must have at least `minWeeklyDownloads` downloads last week and a release within `maxMonthsSinceRelease` months; and installing it must run no scripts, unless `allowInstallScripts`
|
|
415
|
+
- **Dependencies:** each package is checked against the npm registry. Its licence must be in `licences`; it must have at least `minWeeklyDownloads` downloads last week and a release within `maxMonthsSinceRelease` months; and installing it must run no scripts, unless `allowInstallScripts` allows them: `true` for any, or a list of the kinds allowed, such as `["binary"]` for packages that only download a prebuilt binary for the platform (esbuild, sharp). The kinds are `binary`, `build` (compiles native code with node-gyp) and `other` (runs the package's own code). Packages from other ecosystems, and anything the registry can't confirm, go to the person.
|
|
415
416
|
- **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.
|
|
416
417
|
- **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.
|
|
417
418
|
- **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.
|
package/bin/code-kit.mjs
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// (approvals.delegate.merge) or the person (the code-kit pane)
|
|
20
20
|
// requests [--json] open approval requests and approvals in force
|
|
21
21
|
// stops [--session id] [--json] finish checks that refused an agent's last stop, still failing
|
|
22
|
+
// sent-back <branch> [--reason …] records that a review sent the branch back at its current commit
|
|
22
23
|
// record approvals a person gave in chat (with approvals.lead on, the lead runs it)
|
|
23
24
|
// --config <file> reads a draft (.claude/code-kit.draft.json) instead of .claude/code-kit.json.
|
|
24
25
|
import { execFileSync } from 'node:child_process';
|
|
@@ -34,8 +35,10 @@ import { ADAPTERS, activeAdapters } from '../hooks/lib/adapters/index.mjs';
|
|
|
34
35
|
import { nextStep } from '../hooks/lib/next.mjs';
|
|
35
36
|
import { traceFile } from '../hooks/lib/trace.mjs';
|
|
36
37
|
import { buildStatus } from '../hooks/lib/plan.mjs';
|
|
38
|
+
import { recordSentBack } from '../hooks/lib/reviews.mjs';
|
|
37
39
|
import {
|
|
38
40
|
APPROVAL_MINUTES,
|
|
41
|
+
approvalLifetime,
|
|
39
42
|
currentBranch,
|
|
40
43
|
PANE,
|
|
41
44
|
approvalsInForce,
|
|
@@ -43,10 +46,11 @@ import {
|
|
|
43
46
|
grantApprovals,
|
|
44
47
|
openRequests,
|
|
45
48
|
ownerOf,
|
|
49
|
+
reviewerOf,
|
|
46
50
|
} from '../hooks/lib/rules.mjs';
|
|
47
51
|
import { DEPENDENCY_APPROVAL, dependencyApproval } from '../hooks/lib/dependencies.mjs';
|
|
48
52
|
import { dependencyRuleProblems, npmFacts, packageOf } from '../hooks/lib/registry.mjs';
|
|
49
|
-
import { laneOfBranch, verifyBranch } from '../hooks/lib/verify.mjs';
|
|
53
|
+
import { heldTo, laneOfBranch, verifyBranch } from '../hooks/lib/verify.mjs';
|
|
50
54
|
|
|
51
55
|
const out = (s) => process.stdout.write(`${s}\n`);
|
|
52
56
|
// Piped into `head` and the like: stop quietly when the reader closes.
|
|
@@ -135,7 +139,13 @@ function who(paths) {
|
|
|
135
139
|
const { config } = load();
|
|
136
140
|
for (const p of paths) {
|
|
137
141
|
const layer = layerOf(p, config.layers);
|
|
138
|
-
|
|
142
|
+
const reviewer = reviewerOf(p, config);
|
|
143
|
+
const whose =
|
|
144
|
+
ownerOf(p, config) ??
|
|
145
|
+
(reviewer
|
|
146
|
+
? `any agent may write it; ${reviewer === 'lead' ? 'the lead' : `the ${reviewer} lane`} reviews it`
|
|
147
|
+
: 'nobody');
|
|
148
|
+
out(`${p}: ${whose}${layer ? ` · layer ${layer.name}` : ''}`);
|
|
139
149
|
}
|
|
140
150
|
}
|
|
141
151
|
|
|
@@ -288,6 +298,27 @@ const VERIFY_GROUPS = {
|
|
|
288
298
|
// merges, and only when `verify` passes on that branch, checks included. A person merges with git.
|
|
289
299
|
// With --person, the person merges (the mod passes it for a press in its pane): the same verify first,
|
|
290
300
|
// with no delegation needed. The hooks refuse --person from every agent.
|
|
301
|
+
/**
|
|
302
|
+
* The config as committed on `ref`: what governs merging into it, so a branch's own config can't grant
|
|
303
|
+
* itself a merge its target doesn't allow.
|
|
304
|
+
*/
|
|
305
|
+
function configAt(ref) {
|
|
306
|
+
let raw;
|
|
307
|
+
try {
|
|
308
|
+
raw = JSON.parse(
|
|
309
|
+
execFileSync('git', ['show', `${ref}:${file}`], {
|
|
310
|
+
encoding: 'utf8',
|
|
311
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
} catch {
|
|
315
|
+
die(`${ref} has no readable ${file}; a merge into it follows the rules committed there.`);
|
|
316
|
+
}
|
|
317
|
+
const problems = validate(raw);
|
|
318
|
+
if (problems.length) die(`${file} on ${ref} is invalid:\n ${problems.join('\n ')}`);
|
|
319
|
+
return effectiveConfig(raw, '.');
|
|
320
|
+
}
|
|
321
|
+
|
|
291
322
|
function merge(branch) {
|
|
292
323
|
const { config } = load();
|
|
293
324
|
const byPerson = flag('--person');
|
|
@@ -297,14 +328,16 @@ function merge(branch) {
|
|
|
297
328
|
);
|
|
298
329
|
if (flag('--delegated') && byPerson)
|
|
299
330
|
die("A merge is either the lead's (--delegated) or the person's (--person), not both.");
|
|
300
|
-
|
|
331
|
+
const into = intoName ?? config.branches.protected[0];
|
|
332
|
+
// Whether the lead may merge, and which branches are protected, are the target's rules.
|
|
333
|
+
const target = configAt(into);
|
|
334
|
+
if (!byPerson && !target.approvals.delegate?.merge)
|
|
301
335
|
die(
|
|
302
|
-
|
|
336
|
+
`${into} doesn't delegate merges to the lead ("approvals.delegate.merge" isn't true in its ${file}). A person merges after review.`,
|
|
303
337
|
);
|
|
304
|
-
|
|
305
|
-
if (!config.branches.protected.includes(into))
|
|
338
|
+
if (!target.branches.protected.includes(into))
|
|
306
339
|
die(`${into} isn't a protected branch; merge into it with git.`);
|
|
307
|
-
if (
|
|
340
|
+
if (target.branches.protected.includes(branch)) die(`${branch} is itself a protected branch.`);
|
|
308
341
|
const root = resolve('.');
|
|
309
342
|
const g = (cwd, ...a) =>
|
|
310
343
|
execFileSync('git', ['-C', cwd, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
@@ -374,7 +407,7 @@ function merge(branch) {
|
|
|
374
407
|
'-m',
|
|
375
408
|
`Merge ${branch} into ${into}`,
|
|
376
409
|
'-m',
|
|
377
|
-
`Verified with code-kit verify (${changed.length} file(s)${lane ? `, held to
|
|
410
|
+
`Verified with code-kit verify (${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}) and merged ${byPerson ? 'by the person, from the code-kit pane' : 'by the lead within the delegated rules'}.`,
|
|
378
411
|
branch,
|
|
379
412
|
);
|
|
380
413
|
} catch (e) {
|
|
@@ -388,7 +421,7 @@ function merge(branch) {
|
|
|
388
421
|
);
|
|
389
422
|
}
|
|
390
423
|
out(
|
|
391
|
-
`Merged ${branch} into ${into}: verify passed on ${changed.length} file(s)${lane ? `, held to
|
|
424
|
+
`Merged ${branch} into ${into}: verify passed on ${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}.`,
|
|
392
425
|
);
|
|
393
426
|
} finally {
|
|
394
427
|
cleanup();
|
|
@@ -413,6 +446,24 @@ function requests() {
|
|
|
413
446
|
);
|
|
414
447
|
}
|
|
415
448
|
|
|
449
|
+
/** Records the review's send-back of `branch` at its current commit (lib/reviews.mjs). */
|
|
450
|
+
function sentBackCommand(branch) {
|
|
451
|
+
load();
|
|
452
|
+
let sha;
|
|
453
|
+
try {
|
|
454
|
+
sha = execFileSync('git', ['rev-parse', '--verify', `${branch}^{commit}`], {
|
|
455
|
+
encoding: 'utf8',
|
|
456
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
457
|
+
}).trim();
|
|
458
|
+
} catch {
|
|
459
|
+
die(`No branch ${branch} here.`);
|
|
460
|
+
}
|
|
461
|
+
recordSentBack(resolve('.'), branch, sha, reason);
|
|
462
|
+
out(
|
|
463
|
+
`Recorded: ${branch} was sent back at ${sha.slice(0, 7)}. status shows its story as sent back until the branch moves on.`,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
416
467
|
/** Finish checks still failing: the stop hook's last refusal for each session and agent. */
|
|
417
468
|
function stops() {
|
|
418
469
|
load();
|
|
@@ -472,7 +523,7 @@ function verify() {
|
|
|
472
523
|
return;
|
|
473
524
|
}
|
|
474
525
|
out(
|
|
475
|
-
`${changed.length} file(s) changed since ${from}${lane ? `, held to
|
|
526
|
+
`${changed.length} file(s) changed since ${from}${lane ? `, held to ${heldTo(lane)} (branch ${branch})` : ''}.`,
|
|
476
527
|
);
|
|
477
528
|
let count = 0;
|
|
478
529
|
for (const [key, title] of Object.entries(VERIFY_GROUPS)) {
|
|
@@ -576,7 +627,10 @@ function adapters() {
|
|
|
576
627
|
if (!active.includes(a.name)) continue;
|
|
577
628
|
const c = a.config ?? {};
|
|
578
629
|
if (c.lead?.length) out(` lead writes: ${c.lead.join(', ')}`);
|
|
579
|
-
if (c.anyActor?.length)
|
|
630
|
+
if (c.anyActor?.length)
|
|
631
|
+
out(
|
|
632
|
+
` anyone writes: ${c.anyActor.map((e) => (typeof e === 'string' ? e : `${e.glob} (reviewed by ${e.reviewer ?? 'the lead'})`)).join(', ')}`,
|
|
633
|
+
);
|
|
580
634
|
for (const p of c.protected ?? [])
|
|
581
635
|
out(` protected: ${p.glob} (approval "${p.approval}": ${p.why})`);
|
|
582
636
|
for (const b of a.shell?.block ?? []) out(` blocked command: /${b.pattern}/ — ${b.why}`);
|
|
@@ -591,7 +645,7 @@ function trace(paths) {
|
|
|
591
645
|
for (const t of traces) {
|
|
592
646
|
out(t.path);
|
|
593
647
|
out(
|
|
594
|
-
` owner: ${t.owner ?? 'nobody'}${t.layer ? ` · layer ${t.layer.name} → ${t.layer.mayImport.join(', ') || 'only itself'}` : ''}`,
|
|
648
|
+
` owner: ${t.owner ?? (t.reviewer ? `any agent; the ${t.reviewer === 'lead' ? 'lead' : `${t.reviewer} lane`} reviews it` : 'nobody')}${t.layer ? ` · layer ${t.layer.name} → ${t.layer.mayImport.join(', ') || 'only itself'}` : ''}`,
|
|
595
649
|
);
|
|
596
650
|
if (!t.requirements.length) out(' no requirements traced (no commit names a story)');
|
|
597
651
|
for (const r of t.requirements)
|
|
@@ -661,7 +715,7 @@ async function approve(given) {
|
|
|
661
715
|
const whom = laneName ? `the ${laneName} lane` : 'any agent';
|
|
662
716
|
const renamed = given.flatMap((g, i) => (g === names[i] ? [] : [`${g} → ${names[i]}`]));
|
|
663
717
|
out(
|
|
664
|
-
`Approved ${names.join(', ')} for ${whom},
|
|
718
|
+
`Approved ${names.join(', ')} for ${whom}, ${approvalLifetime(names)}${delegated ? ', within the delegated rules' : ''}: ${reason.trim()}${renamed.length ? `\n(${renamed.join(', ')})` : ''}`,
|
|
665
719
|
);
|
|
666
720
|
}
|
|
667
721
|
|
|
@@ -671,6 +725,7 @@ const commands = {
|
|
|
671
725
|
merge: () => (rest.length === 1 ? merge(rest[0]) : usage()),
|
|
672
726
|
requests,
|
|
673
727
|
stops,
|
|
728
|
+
'sent-back': () => (rest.length === 1 ? sentBackCommand(rest[0]) : usage()),
|
|
674
729
|
trace: () => (rest.length ? trace(rest) : usage()),
|
|
675
730
|
adapters,
|
|
676
731
|
next,
|
|
@@ -689,7 +744,7 @@ function usage() {
|
|
|
689
744
|
' | next [--base ref] [--json] | adapters | trace <path>... [--json]\n' +
|
|
690
745
|
' | approve <name>... --reason "…" [--lane name] [--config file]\n' +
|
|
691
746
|
' | merge <branch> --delegated|--person [--into branch] | requests [--json]\n' +
|
|
692
|
-
' | stops [--session id] [--json]',
|
|
747
|
+
' | stops [--session id] [--json] | sent-back <branch> [--reason "…"]',
|
|
693
748
|
);
|
|
694
749
|
}
|
|
695
750
|
await (commands[command] ?? usage)();
|
package/hooks/guard-bash.mjs
CHANGED
|
@@ -5,19 +5,22 @@
|
|
|
5
5
|
// approval log on commit and scans commits for secrets. Shell writes are also checked afterwards by
|
|
6
6
|
// lane-audit.mjs. With `approvals.lead` on, the lead may act for the person on what they say in chat:
|
|
7
7
|
// record their approvals (`code-kit approve`) and run blocked commands marked `person`.
|
|
8
|
-
import { appendFileSync } from 'node:fs';
|
|
8
|
+
import { appendFileSync, existsSync, readFileSync } from 'node:fs';
|
|
9
9
|
import { spawnSync } from 'node:child_process';
|
|
10
10
|
import { join, resolve } from 'node:path';
|
|
11
|
-
import { addedPackages, dependencyApproval } from './lib/dependencies.mjs';
|
|
11
|
+
import { addedPackages, dependencyApproval, isDependencyFile } from './lib/dependencies.mjs';
|
|
12
|
+
import { CODE_KIT, GIT, codeOf, runs } from './lib/shell.mjs';
|
|
12
13
|
import { block, start } from './lib/hook.mjs';
|
|
13
14
|
import {
|
|
14
15
|
APPROVAL_LOG,
|
|
15
16
|
actorFor,
|
|
17
|
+
arrivesWithMerge,
|
|
16
18
|
approval,
|
|
17
19
|
approvalHowTo,
|
|
18
20
|
currentBranch,
|
|
19
21
|
dependencyChange,
|
|
20
22
|
dependencyGrants,
|
|
23
|
+
useApproval,
|
|
21
24
|
owns,
|
|
22
25
|
protectedEntry,
|
|
23
26
|
recordRequest,
|
|
@@ -27,28 +30,46 @@ import {
|
|
|
27
30
|
const { input, project, config, error } = start();
|
|
28
31
|
const cmd = String(input.tool_input?.command || '');
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
// Commands are recognised where they run (lib/shell.mjs), so a commit message, a heredoc, a quoted
|
|
34
|
+
// --text or a grep pattern that mentions one doesn't trip its rule.
|
|
35
|
+
const RUNS = [
|
|
36
|
+
[
|
|
37
|
+
new RegExp(String.raw`^${GIT}push\b.*(--force\b|\s-f\b|--force-with-lease\b)`),
|
|
38
|
+
'Force-push is not allowed.',
|
|
39
|
+
],
|
|
40
|
+
[new RegExp(String.raw`^${GIT}\S+\b.*\s--no-verify\b`), 'Skipping git hooks is not allowed.'],
|
|
33
41
|
[
|
|
34
|
-
|
|
42
|
+
/^rm\s+-rf?\s+(\/|~|\$HOME|\.\.?)(\s|$)/,
|
|
35
43
|
'Refusing a destructive delete of a root, home or parent directory.',
|
|
36
44
|
],
|
|
37
|
-
|
|
38
|
-
|
|
45
|
+
];
|
|
46
|
+
for (const [re, why] of RUNS) {
|
|
47
|
+
if (runs(cmd, re, { quoted: false })) block(`Blocked: ${why}\nCommand: ${cmd}`);
|
|
48
|
+
}
|
|
49
|
+
// These read the command whole: a pipe spans commands, and an approval or the log can be written
|
|
50
|
+
// through a quoted path.
|
|
51
|
+
const ALWAYS = [
|
|
52
|
+
[
|
|
53
|
+
/\b(curl|wget)\b[^|]*\|\s*(sh|bash|zsh)\b/,
|
|
54
|
+
'Piping downloads into a shell is not allowed.',
|
|
55
|
+
codeOf(cmd),
|
|
56
|
+
],
|
|
57
|
+
[/\.claude\/approvals/, 'Approvals are created only by a person, with a `!` shell command.', cmd],
|
|
39
58
|
[
|
|
40
59
|
/approval-log\.jsonl/,
|
|
41
60
|
'The approval audit log is appended only by the commit hook; nobody edits it.',
|
|
61
|
+
cmd,
|
|
42
62
|
],
|
|
43
63
|
];
|
|
44
|
-
for (const [re, why] of ALWAYS) {
|
|
45
|
-
if (re.test(
|
|
64
|
+
for (const [re, why, text] of ALWAYS) {
|
|
65
|
+
if (re.test(text)) block(`Blocked: ${why}\nCommand: ${cmd}`);
|
|
46
66
|
}
|
|
47
67
|
const packages = addedPackages(cmd);
|
|
48
|
-
const approves =
|
|
49
|
-
const delegatedMerge =
|
|
68
|
+
const approves = runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+approve\b`));
|
|
69
|
+
const delegatedMerge = runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+merge\b`));
|
|
70
|
+
const commits = runs(cmd, new RegExp(String.raw`^${GIT}commit\b`));
|
|
50
71
|
if (error) {
|
|
51
|
-
if (
|
|
72
|
+
if (commits) block(`Blocked: ${error}\nFix the config before committing.`);
|
|
52
73
|
if (approves) block(`Blocked: ${error}\nApprovals wait until the config is valid.`);
|
|
53
74
|
if (delegatedMerge) block(`Blocked: ${error}\nMerges wait until the config is valid.`);
|
|
54
75
|
if (packages.length)
|
|
@@ -62,23 +83,35 @@ const branches = config.branches.protected
|
|
|
62
83
|
// The branch is read before the command runs, so a command that switches to a protected branch and then
|
|
63
84
|
// commits or merges in the same breath is judged by what it does, not by where it starts. Moving a
|
|
64
85
|
// protected branch directly (branch -f, update-ref) is a commit on it by another name.
|
|
65
|
-
const writesHistory =
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
)
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
`\\bgit\\s+branch\\s+(?:\\S+\\s+)*(?:-f|--force|-M|-C)\\b.*\\b(${branches})(?![\\w./-])`,
|
|
72
|
-
).test(cmd) ||
|
|
86
|
+
const writesHistory = runs(
|
|
87
|
+
cmd,
|
|
88
|
+
new RegExp(String.raw`^${GIT}(commit|merge|cherry-pick|rebase|revert|am)\b`),
|
|
89
|
+
);
|
|
90
|
+
const switchesToProtected = runs(
|
|
91
|
+
cmd,
|
|
73
92
|
new RegExp(
|
|
74
|
-
|
|
75
|
-
)
|
|
93
|
+
String.raw`^${GIT}(?:switch|checkout)\s+(?:-[^\s]+\s+)*(${branches})(?![\w./-])(?!\s+--)`,
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
const movesProtected =
|
|
97
|
+
runs(
|
|
98
|
+
cmd,
|
|
99
|
+
new RegExp(
|
|
100
|
+
String.raw`^${GIT}branch\s+(?:\S+\s+)*(?:-f|--force|-M|-C)\b.*\b(${branches})(?![\w./-])`,
|
|
101
|
+
),
|
|
102
|
+
) ||
|
|
103
|
+
runs(
|
|
104
|
+
cmd,
|
|
105
|
+
new RegExp(
|
|
106
|
+
String.raw`^${GIT}update-ref\s+(?:-\S+\s+)*(?:refs/heads/)?(${branches})(?![\w./-])`,
|
|
107
|
+
),
|
|
108
|
+
);
|
|
76
109
|
if ((switchesToProtected && writesHistory) || movesProtected) {
|
|
77
110
|
block(
|
|
78
111
|
`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
112
|
);
|
|
80
113
|
}
|
|
81
|
-
if (new RegExp(
|
|
114
|
+
if (runs(cmd, new RegExp(String.raw`^${GIT}push\b.*\b(${branches})\b`))) {
|
|
82
115
|
block(
|
|
83
116
|
`Blocked: push to your own branch; a person merges to ${config.branches.protected[0]} after review.\nCommand: ${cmd}`,
|
|
84
117
|
);
|
|
@@ -180,10 +213,7 @@ try {
|
|
|
180
213
|
} catch {
|
|
181
214
|
// not a git repository yet
|
|
182
215
|
}
|
|
183
|
-
if (
|
|
184
|
-
/\bgit\s+(commit|merge|cherry-pick|rebase|revert)\b/.test(cmd) &&
|
|
185
|
-
config.branches.protected.includes(branch)
|
|
186
|
-
) {
|
|
216
|
+
if (writesHistory && config.branches.protected.includes(branch)) {
|
|
187
217
|
block(
|
|
188
218
|
`Blocked: never commit or merge on ${branch}. Work on a branch; a person merges after review.`,
|
|
189
219
|
);
|
|
@@ -253,6 +283,8 @@ function recordApprovals() {
|
|
|
253
283
|
}),
|
|
254
284
|
);
|
|
255
285
|
for (const file of files) {
|
|
286
|
+
// Concluding a merge: what arrives as the other branch has it was logged there, with its approvals.
|
|
287
|
+
if (arrivesWithMerge(root, file, { staged: true })) continue;
|
|
256
288
|
const entry = protectedEntry(file, config);
|
|
257
289
|
const granted = entry && approval(root, entry.approval, actor);
|
|
258
290
|
const byDependency = actor.kind === 'lane' && dependencyChange(actor, file, root, config);
|
|
@@ -283,12 +315,30 @@ function recordApprovals() {
|
|
|
283
315
|
grantedAt: null,
|
|
284
316
|
});
|
|
285
317
|
}
|
|
318
|
+
// A dependency approval is used up by the commit that adds its package to a manifest or lockfile.
|
|
319
|
+
const manifests = files.filter((f) => isDependencyFile(f));
|
|
320
|
+
if (manifests.length && actor.kind !== 'readonly') {
|
|
321
|
+
// The lines each manifest gains on HEAD, read from the working tree: this hook runs before
|
|
322
|
+
// `git add … && git commit` has staged anything, and a new manifest isn't tracked yet.
|
|
323
|
+
const added = manifests
|
|
324
|
+
.flatMap((f) => {
|
|
325
|
+
const before = new Set(git('show', `HEAD:${f}`).split('\n'));
|
|
326
|
+
const now = existsSync(join(root, f)) ? readFileSync(join(root, f), 'utf8') : '';
|
|
327
|
+
return now.split('\n').filter((l) => !before.has(l));
|
|
328
|
+
})
|
|
329
|
+
.join('\n');
|
|
330
|
+
for (const g of dependencyGrants(root, actor)) {
|
|
331
|
+
const pkg = g.name.slice('dep-'.length).replaceAll('+', '/');
|
|
332
|
+
const quoted = pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
333
|
+
if (new RegExp(`["'\\s/]${quoted}["'@=:\\s]`).test(added)) useApproval(root, g.name, actor);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
286
336
|
if (lines.length === 0) return;
|
|
287
337
|
appendFileSync(join(root, APPROVAL_LOG), `${lines.join('\n')}\n`);
|
|
288
338
|
git('add', APPROVAL_LOG);
|
|
289
339
|
}
|
|
290
340
|
|
|
291
|
-
if (
|
|
341
|
+
if (commits) {
|
|
292
342
|
recordApprovals();
|
|
293
343
|
const scan = spawnSync('gitleaks', ['protect', '--staged', '--redact', '--no-banner'], {
|
|
294
344
|
cwd: root,
|
package/hooks/lane-audit.mjs
CHANGED
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
import { readFileSync } from 'node:fs';
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
APPROVAL_LOG,
|
|
10
|
+
LANE_FILE,
|
|
11
|
+
actorFor,
|
|
12
|
+
actorRoot,
|
|
13
|
+
arrivesWithMerge,
|
|
14
|
+
writeProblem,
|
|
15
|
+
} from './lib/rules.mjs';
|
|
9
16
|
|
|
10
17
|
// The commit hook appends to the approval log and stages it; if that commit then fails, the
|
|
11
18
|
// appended lines are left uncommitted. That is the only change to the log that is not a problem.
|
|
@@ -36,11 +43,15 @@ export function auditProblems(input, config) {
|
|
|
36
43
|
const root = actorRoot(input);
|
|
37
44
|
if (!root) return [];
|
|
38
45
|
const actor = actorFor(input, root, config);
|
|
39
|
-
return
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
return (
|
|
47
|
+
changedFiles(root)
|
|
48
|
+
.filter((rel) => !(rel === APPROVAL_LOG && appendedOnly(root, rel)))
|
|
49
|
+
// Mid-merge, the incoming branch's changes are its own, not this actor's writes.
|
|
50
|
+
.filter((rel) => !arrivesWithMerge(root, rel))
|
|
51
|
+
.map((rel) => [rel, writeProblem(actor, rel, root, config)])
|
|
52
|
+
.filter(([, problem]) => problem)
|
|
53
|
+
.map(([rel, problem]) => `${rel}: ${problem.split('\n')[0]}`)
|
|
54
|
+
);
|
|
44
55
|
}
|
|
45
56
|
|
|
46
57
|
if (import.meta.url === `file://${process.argv[1]}`) {
|