@warren-dean/code-kit 0.3.12 → 0.4.1
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 +34 -0
- package/README.md +33 -7
- package/bin/code-kit.mjs +113 -10
- package/hooks/guard-bash.mjs +21 -0
- package/hooks/hooks.json +1 -0
- package/hooks/lib/adapters/context-graph.mjs +16 -1
- package/hooks/lib/adapters/index.mjs +4 -1
- package/hooks/lib/rules.mjs +102 -0
- package/hooks/lib/verify.mjs +19 -2
- package/hooks/mod/register.mjs +359 -0
- package/hooks/mod/register.test.ts +714 -0
- package/hooks/mod/view.mjs +418 -0
- package/hooks/stop-check.mjs +12 -1
- package/package.json +2 -2
|
@@ -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.4.1",
|
|
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.4.1",
|
|
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,40 @@
|
|
|
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.4.1 (2026-10-07)
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
|
|
9
|
+
## 0.4.0 (2026-10-07)
|
|
10
|
+
|
|
11
|
+
code-kit in the session: a mod for Claude Code 2.1.287 or later, in the terminal and the Desktop app. Older versions skip it and keep enforcing everything through the hooks, as before. See [In the session](README.md#in-the-session).
|
|
12
|
+
|
|
13
|
+
- **The band** above the prompt shows what waits for you: approvals agents asked for (**Approve…**, with a reason you keep or rewrite), stories ready for review (**Review**, **Merge**), and a finish check failing. Each has a digit hotkey, and the band is gone when nothing waits.
|
|
14
|
+
- **`/lanes`** opens a pane with each lane's agent, story, branch and state, the agents at work, and what's ready. It stays current within 2 seconds of a change.
|
|
15
|
+
- **Refusal cards:** a code-kit refusal reads as a card with the rule, what to do, and Approve… where an approval would allow it.
|
|
16
|
+
- **`/approvals`** and **`/verify-branch`** print without a model call.
|
|
17
|
+
- **New in the CLI:** `code-kit stops` lists finish checks still failing, and `check --json` reports the config's state for the mod.
|
|
18
|
+
- A merge from the pane says so in its commit.
|
|
19
|
+
- `code-kit` with no command now prints its whole usage.
|
|
20
|
+
|
|
21
|
+
## 0.3.15 (2026-10-06)
|
|
22
|
+
|
|
23
|
+
- **`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.
|
|
24
|
+
|
|
25
|
+
## 0.3.14 (2026-10-06)
|
|
26
|
+
|
|
27
|
+
- **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.
|
|
28
|
+
|
|
29
|
+
## 0.3.13 (2026-10-06)
|
|
30
|
+
|
|
31
|
+
The command-line groundwork for the coming code-kit mod (`docs/brief.md`), useful on its own:
|
|
32
|
+
|
|
33
|
+
- **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.
|
|
34
|
+
- **`code-kit approve … --via pane`** marks an approval as given in the code-kit pane.
|
|
35
|
+
- **`code-kit merge <branch> --person`:** the person merges a branch after `verify` passes, with no delegation needed.
|
|
36
|
+
- **Agents:** the hooks refuse `--via` and `--person` from every agent.
|
|
37
|
+
- **`code-kit verify --json`** prints the branch, its lane and the problems by group.
|
|
38
|
+
|
|
5
39
|
## 0.3.12 (2026-10-05)
|
|
6
40
|
|
|
7
41
|
- **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.
|
package/README.md
CHANGED
|
@@ -101,6 +101,7 @@ Set it up at the start of a new build, straight from the architecture docs, or b
|
|
|
101
101
|
| `Stop`, `SubagentStop` | `hooks/stop-check.mjs` | proof before finishing |
|
|
102
102
|
|
|
103
103
|
- **For the main session and every subagent**, including agents in worktrees under `.claude/worktrees/`.
|
|
104
|
+
- **In front of you, in the terminal and the Desktop app,** through the code-kit mod (Claude Code 2.1.287 or later; see [In the session](#in-the-session)). It shows and acts, and never enforces: the hooks above do that everywhere, the mod or not.
|
|
104
105
|
- **In CI**, through `code-kit verify` in a GitHub Actions workflow that init can install (`templates/github-workflow.yml`).
|
|
105
106
|
- **Locally, with Node 22 or later.** Nothing is sent anywhere. The CLI in `bin/` runs the same rules from a terminal.
|
|
106
107
|
|
|
@@ -261,6 +262,23 @@ Editing an agent file can't loosen a rule. And the agent files are kit files (`.
|
|
|
261
262
|
|
|
262
263
|
**Kept in step.** When the project changes (a new app, a new lane, moved folders), re-run `/code-kit:init`. It drafts new agents for new lanes and updates the paths of existing ones, keeping anything written by hand. You see the difference and approve it before anything changes.
|
|
263
264
|
|
|
265
|
+
## In the session
|
|
266
|
+
|
|
267
|
+
With Claude Code 2.1.287 or later, code-kit's mod shows the project in the session, in the terminal (VS Code's terminal included) and the Desktop app. Older versions skip it and keep enforcing everything through the hooks.
|
|
268
|
+
|
|
269
|
+
- **The band** sits above the prompt whenever something waits for you, and is gone when nothing does. It shows:
|
|
270
|
+
- approvals agents have asked for: **Approve…** opens a confirmation with a reason prefilled from the request, which you keep or rewrite;
|
|
271
|
+
- stories finished and waiting for review: **Review** asks the lead to run `/code-kit:review` on the branch, and **Merge** runs `verify`, checks included, then merges if it passes;
|
|
272
|
+
- a finish check failing in this session: **Lanes** opens the pane.
|
|
273
|
+
|
|
274
|
+
Each button has a digit: type it in an empty prompt to press it.
|
|
275
|
+
|
|
276
|
+
- **`/lanes`** opens the Lanes pane: each lane with its agent, story, branch and state (building, in review, blocked, idle), a mark while its agent is at work, and the stories ready to start. It stays current within 2 seconds of a commit, a branch change, a plan edit or an agent starting or stopping. `/lanes` again, or Escape, closes it.
|
|
277
|
+
- **Refusals** a code-kit hook gives an agent are drawn as cards: what was refused, the rule, what to do, and Approve… where your approval would allow it. The full text is a press away.
|
|
278
|
+
- **`/approvals`** lists the requests waiting and the approvals in force, with minutes left. **`/verify-branch`** runs `code-kit verify` on this branch. Neither calls the model.
|
|
279
|
+
|
|
280
|
+
Approving from the band records `(approved in the code-kit pane)` with your reason, and a merge from it says it was merged by the person from the pane. Both happen only on your press: the hooks refuse `approve --via pane` and `merge --person` from every agent. Outside a project with `.claude/code-kit.json`, the mod draws nothing. Mods don't draw in the VS Code extension's chat panel, the Agent SDK or `claude -p`.
|
|
281
|
+
|
|
264
282
|
## Build
|
|
265
283
|
|
|
266
284
|
Once init has switched the rules on, the lead runs the build in a loop:
|
|
@@ -299,18 +317,20 @@ Lanes never change silently: only when you approve the diff.
|
|
|
299
317
|
Run from the project root, as `code-kit <command>` once installed (`npm i -g @warren-dean/code-kit`), or `npx @warren-dean/code-kit <command>`. Add `--config .claude/code-kit.draft.json` to any command to read a draft instead. In a session, the `/code-kit:check` skill runs `check` and `unowned` for you. `next` also works in a project without code-kit, or outside a git repository.
|
|
300
318
|
|
|
301
319
|
```bash
|
|
302
|
-
code-kit check
|
|
320
|
+
code-kit check [--json] # validate and summarise
|
|
303
321
|
code-kit who <path>... # owner and layer of each path
|
|
304
322
|
code-kit unowned # tracked files nobody may write
|
|
305
323
|
code-kit diff --config .claude/code-kit.draft.json # what a draft changes
|
|
306
324
|
code-kit baseline [--write] # layer violations in the code as it is
|
|
307
325
|
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
|
|
326
|
+
code-kit verify [--base ref] [--branch name] [--no-checks] [--json] # a branch's changes, for CI and review
|
|
309
327
|
code-kit status [--base ref] [--json] # stories and requirements against the spec
|
|
310
328
|
code-kit next [--base ref] [--json] # the step to take now, from the project's state
|
|
311
329
|
code-kit trace <path>... [--json] # what a file is for: requirements, lane, layer rules
|
|
312
|
-
code-kit merge <branch> --delegated [--into branch] #
|
|
313
|
-
code-kit
|
|
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
|
+
code-kit requests [--json] # approval requests the hooks refused, still open, and the approvals in force
|
|
332
|
+
code-kit stops [--session id] [--json] # finish checks that refused an agent's last stop and still fail
|
|
333
|
+
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
|
|
314
334
|
```
|
|
315
335
|
|
|
316
336
|
`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`.
|
|
@@ -372,6 +392,12 @@ code-kit approve dep-dayjs --lane web --reason "Yes, add dayjs for ST-12"
|
|
|
372
392
|
- The lead may also run `shell.block` commands marked `person: true`, such as Context Graph's ratification trailer.
|
|
373
393
|
- It trusts the lead to act only on the person's own words. Leave it off when the lead runs without a person watching.
|
|
374
394
|
|
|
395
|
+
### Requests
|
|
396
|
+
|
|
397
|
+
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.
|
|
398
|
+
|
|
399
|
+
`--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.
|
|
400
|
+
|
|
375
401
|
### Delegated
|
|
376
402
|
|
|
377
403
|
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:
|
|
@@ -404,9 +430,9 @@ Other tools share a project with code-kit and keep files of their own. An adapte
|
|
|
404
430
|
|
|
405
431
|
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 }`.
|
|
406
432
|
|
|
407
|
-
| Adapter | Detected by | Adds
|
|
408
|
-
| --------------- | ----------- |
|
|
409
|
-
| `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. |
|
|
433
|
+
| Adapter | Detected by | Adds |
|
|
434
|
+
| --------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
435
|
+
| `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. |
|
|
410
436
|
|
|
411
437
|
Its setup:
|
|
412
438
|
|
package/bin/code-kit.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Run from the project root: node "${CLAUDE_PLUGIN_ROOT}/bin/code-kit.mjs" <command>
|
|
3
|
-
// check
|
|
3
|
+
// check [--json] validate the config and summarise it
|
|
4
4
|
// who <path>... who may write each path, and its layer
|
|
5
5
|
// unowned tracked files nobody may write
|
|
6
6
|
// diff what a draft changes against the live config, and whose files move
|
|
@@ -15,11 +15,14 @@
|
|
|
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]
|
|
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
|
|
21
|
+
// stops [--session id] [--json] finish checks that refused an agent's last stop, still failing
|
|
19
22
|
// record approvals a person gave in chat (with approvals.lead on, the lead runs it)
|
|
20
23
|
// --config <file> reads a draft (.claude/code-kit.draft.json) instead of .claude/code-kit.json.
|
|
21
24
|
import { execFileSync } from 'node:child_process';
|
|
22
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
23
26
|
import { tmpdir } from 'node:os';
|
|
24
27
|
import { join, posix, resolve } from 'node:path';
|
|
25
28
|
import { BASELINE_FILE, loadBaseline } from '../hooks/lib/baseline.mjs';
|
|
@@ -34,8 +37,11 @@ import { buildStatus } from '../hooks/lib/plan.mjs';
|
|
|
34
37
|
import {
|
|
35
38
|
APPROVAL_MINUTES,
|
|
36
39
|
currentBranch,
|
|
40
|
+
PANE,
|
|
41
|
+
approvalsInForce,
|
|
37
42
|
delegatedMark,
|
|
38
43
|
grantApprovals,
|
|
44
|
+
openRequests,
|
|
39
45
|
ownerOf,
|
|
40
46
|
} from '../hooks/lib/rules.mjs';
|
|
41
47
|
import { DEPENDENCY_APPROVAL, dependencyApproval } from '../hooks/lib/dependencies.mjs';
|
|
@@ -65,7 +71,9 @@ const branchName = option('--branch');
|
|
|
65
71
|
const reason = option('--reason');
|
|
66
72
|
const laneName = option('--lane');
|
|
67
73
|
const intoName = option('--into');
|
|
68
|
-
const
|
|
74
|
+
const via = option('--via');
|
|
75
|
+
const sessionId = option('--session');
|
|
76
|
+
const FLAGS = ['--write', '--no-checks', '--json', '--delegated', '--person'];
|
|
69
77
|
const flag = (name) => args.includes(name);
|
|
70
78
|
const writeBaseline = flag('--write');
|
|
71
79
|
const [command, ...rest] = args.filter((a) => !FLAGS.includes(a));
|
|
@@ -86,7 +94,32 @@ function load(path = file) {
|
|
|
86
94
|
return { raw, config: effectiveConfig(raw, '.') };
|
|
87
95
|
}
|
|
88
96
|
|
|
97
|
+
/** The config as the mod reads it: whether it's there and valid, and the lead's and lanes' paths. */
|
|
98
|
+
function checkJson() {
|
|
99
|
+
const report = { file, exists: existsSync(resolve(file)), valid: false, problems: [] };
|
|
100
|
+
if (!report.exists) return (out(JSON.stringify(report, null, 2)), process.exit(1));
|
|
101
|
+
let raw;
|
|
102
|
+
try {
|
|
103
|
+
raw = JSON.parse(read(file));
|
|
104
|
+
} catch (e) {
|
|
105
|
+
report.problems = [e.message];
|
|
106
|
+
return (out(JSON.stringify(report, null, 2)), process.exit(1));
|
|
107
|
+
}
|
|
108
|
+
report.problems = validate(raw);
|
|
109
|
+
if (report.problems.length) return (out(JSON.stringify(report, null, 2)), process.exit(1));
|
|
110
|
+
const config = effectiveConfig(raw, '.');
|
|
111
|
+
report.valid = true;
|
|
112
|
+
report.lead = { paths: config.lead.paths };
|
|
113
|
+
report.lanes = Object.fromEntries(
|
|
114
|
+
Object.entries(config.lanes).map(([name, l]) => [name, { agent: l.agent, paths: l.paths }]),
|
|
115
|
+
);
|
|
116
|
+
report.adapters = config.adapters;
|
|
117
|
+
report.docs = config.docs ?? null;
|
|
118
|
+
out(JSON.stringify(report, null, 2));
|
|
119
|
+
}
|
|
120
|
+
|
|
89
121
|
function check() {
|
|
122
|
+
if (flag('--json')) return checkJson();
|
|
90
123
|
const { raw } = load();
|
|
91
124
|
out(`${file} is valid.`);
|
|
92
125
|
out(`Lead: ${raw.lead.paths.join(', ')}`);
|
|
@@ -253,13 +286,18 @@ const VERIFY_GROUPS = {
|
|
|
253
286
|
|
|
254
287
|
// The lead merges a reviewed branch into a protected branch on its own, only where the config delegates
|
|
255
288
|
// merges, and only when `verify` passes on that branch, checks included. A person merges with git.
|
|
289
|
+
// With --person, the person merges (the mod passes it for a press in its pane): the same verify first,
|
|
290
|
+
// with no delegation needed. The hooks refuse --person from every agent.
|
|
256
291
|
function merge(branch) {
|
|
257
292
|
const { config } = load();
|
|
258
|
-
|
|
293
|
+
const byPerson = flag('--person');
|
|
294
|
+
if (!flag('--delegated') && !byPerson)
|
|
259
295
|
die(
|
|
260
|
-
|
|
296
|
+
'code-kit merge needs --delegated (the lead, within the delegated rules) or --person (the person, as from the code-kit pane).',
|
|
261
297
|
);
|
|
262
|
-
if (
|
|
298
|
+
if (flag('--delegated') && byPerson)
|
|
299
|
+
die("A merge is either the lead's (--delegated) or the person's (--person), not both.");
|
|
300
|
+
if (!byPerson && !config.approvals.delegate?.merge)
|
|
263
301
|
die(
|
|
264
302
|
'This project doesn\'t delegate merges to the lead ("approvals.delegate.merge" isn\'t true). A person merges after review.',
|
|
265
303
|
);
|
|
@@ -336,7 +374,7 @@ function merge(branch) {
|
|
|
336
374
|
'-m',
|
|
337
375
|
`Merge ${branch} into ${into}`,
|
|
338
376
|
'-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.`,
|
|
377
|
+
`Verified with code-kit verify (${changed.length} file(s)${lane ? `, held to the ${lane} lane` : ''}) and merged ${byPerson ? 'by the person, from the code-kit pane' : 'by the lead within the delegated rules'}.`,
|
|
340
378
|
branch,
|
|
341
379
|
);
|
|
342
380
|
} catch (e) {
|
|
@@ -357,6 +395,51 @@ function merge(branch) {
|
|
|
357
395
|
}
|
|
358
396
|
}
|
|
359
397
|
|
|
398
|
+
// Open approval requests and the approvals in force, for the person and the mod's band.
|
|
399
|
+
function requests() {
|
|
400
|
+
load();
|
|
401
|
+
const open = openRequests(resolve('.'));
|
|
402
|
+
const granted = approvalsInForce(resolve('.'));
|
|
403
|
+
if (flag('--json')) return out(JSON.stringify({ open, inForce: granted }, null, 2));
|
|
404
|
+
out(open.length ? `${open.length} approval request(s) open:` : 'No approval requests open.');
|
|
405
|
+
for (const r of open)
|
|
406
|
+
out(
|
|
407
|
+
` ${r.names.join(', ')} for ${r.lane ? `the ${r.lane} lane` : r.actor}: ${r.what} (${r.why})`,
|
|
408
|
+
);
|
|
409
|
+
out(granted.length ? `\n${granted.length} approval(s) in force:` : '\nNo approvals in force.');
|
|
410
|
+
for (const a of granted)
|
|
411
|
+
out(
|
|
412
|
+
` ${a.name} for ${a.lane ? `the ${a.lane} lane` : 'any agent'}, ${a.minutesLeft} min left: ${a.reason}`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Finish checks still failing: the stop hook's last refusal for each session and agent. */
|
|
417
|
+
function stops() {
|
|
418
|
+
load();
|
|
419
|
+
const dir = resolve('.claude/state/stop-blocks');
|
|
420
|
+
const found = [];
|
|
421
|
+
for (const name of existsSync(dir) ? readdirSync(dir) : []) {
|
|
422
|
+
try {
|
|
423
|
+
const b = JSON.parse(readFileSync(join(dir, name), 'utf8'));
|
|
424
|
+
if (sessionId && b.session !== sessionId) continue;
|
|
425
|
+
found.push({
|
|
426
|
+
session: b.session ?? null,
|
|
427
|
+
agent: b.agent ?? null,
|
|
428
|
+
agentType: b.agentType ?? null,
|
|
429
|
+
title: String(b.message ?? '').split('\n')[0],
|
|
430
|
+
count: b.count ?? 1,
|
|
431
|
+
at: b.at ?? null,
|
|
432
|
+
});
|
|
433
|
+
} catch {
|
|
434
|
+
// a file the hook is still writing, or not one of its own
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (flag('--json')) return out(JSON.stringify(found, null, 2));
|
|
438
|
+
if (!found.length) return out('No finish check is failing.');
|
|
439
|
+
for (const s of found)
|
|
440
|
+
out(`${s.agentType ?? 'the lead'}${s.session ? ` (session ${s.session})` : ''}: ${s.title}`);
|
|
441
|
+
}
|
|
442
|
+
|
|
360
443
|
function verify() {
|
|
361
444
|
const { config } = load();
|
|
362
445
|
const from = base(config, true);
|
|
@@ -376,6 +459,18 @@ function verify() {
|
|
|
376
459
|
lane,
|
|
377
460
|
checks: !flag('--no-checks'),
|
|
378
461
|
});
|
|
462
|
+
if (flag('--json')) {
|
|
463
|
+
const count = Object.values(found).reduce((n, list) => n + list.length, 0);
|
|
464
|
+
out(
|
|
465
|
+
JSON.stringify(
|
|
466
|
+
{ base: from, branch, lane, changed: changed.length, found, problems: count },
|
|
467
|
+
null,
|
|
468
|
+
2,
|
|
469
|
+
),
|
|
470
|
+
);
|
|
471
|
+
if (count) process.exit(1);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
379
474
|
out(
|
|
380
475
|
`${changed.length} file(s) changed since ${from}${lane ? `, held to the ${lane} lane (branch ${branch})` : ''}.`,
|
|
381
476
|
);
|
|
@@ -529,6 +624,11 @@ async function approve(given) {
|
|
|
529
624
|
`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.`,
|
|
530
625
|
);
|
|
531
626
|
const marks = {};
|
|
627
|
+
if (via !== undefined && via !== 'pane')
|
|
628
|
+
die('--via takes only "pane": the code-kit mod passes it for a press in its pane.');
|
|
629
|
+
if (via === 'pane' && delegated)
|
|
630
|
+
die("An approval is either the person's (--via pane) or the lead's (--delegated), not both.");
|
|
631
|
+
if (via === 'pane') for (const n of names) marks[n] = PANE;
|
|
532
632
|
if (delegated) {
|
|
533
633
|
const rules = config.approvals.delegate;
|
|
534
634
|
if (!rules)
|
|
@@ -569,6 +669,8 @@ const commands = {
|
|
|
569
669
|
check,
|
|
570
670
|
approve: () => (rest.length ? approve(rest) : usage()),
|
|
571
671
|
merge: () => (rest.length === 1 ? merge(rest[0]) : usage()),
|
|
672
|
+
requests,
|
|
673
|
+
stops,
|
|
572
674
|
trace: () => (rest.length ? trace(rest) : usage()),
|
|
573
675
|
adapters,
|
|
574
676
|
next,
|
|
@@ -585,8 +687,9 @@ function usage() {
|
|
|
585
687
|
'Usage: code-kit check | who <path>... | unowned | diff | baseline [--write] | graph [--depth N]\n' +
|
|
586
688
|
' | verify [--base ref] [--branch name] [--no-checks] | status [--base ref] [--json]\n' +
|
|
587
689
|
' | next [--base ref] [--json] | adapters | trace <path>... [--json]\n' +
|
|
588
|
-
' | approve <name>... --reason "…" [--lane name] [--config file]'
|
|
589
|
-
|
|
690
|
+
' | approve <name>... --reason "…" [--lane name] [--config file]\n' +
|
|
691
|
+
' | merge <branch> --delegated|--person [--into branch] | requests [--json]\n' +
|
|
692
|
+
' | stops [--session id] [--json]',
|
|
590
693
|
);
|
|
591
694
|
}
|
|
592
695
|
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
|
|
|
@@ -101,6 +102,11 @@ if (approves && actor.kind !== 'lead')
|
|
|
101
102
|
block(
|
|
102
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}`,
|
|
103
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
|
+
);
|
|
104
110
|
if (delegatedMerge && actor.kind !== 'lead')
|
|
105
111
|
block(
|
|
106
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}`,
|
|
@@ -130,6 +136,13 @@ if (packages.length) {
|
|
|
130
136
|
(p) => !approval(sessionIn ?? project, dependencyApproval(p), actor),
|
|
131
137
|
);
|
|
132
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
|
+
);
|
|
133
146
|
const ask =
|
|
134
147
|
actor.kind === 'lead'
|
|
135
148
|
? 'Ask the person to approve it by running'
|
|
@@ -185,6 +198,13 @@ const writes =
|
|
|
185
198
|
cmd,
|
|
186
199
|
);
|
|
187
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
|
+
);
|
|
188
208
|
block(
|
|
189
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)}`,
|
|
190
210
|
);
|
|
@@ -237,6 +257,7 @@ function recordApprovals() {
|
|
|
237
257
|
const granted = entry && approval(root, entry.approval, actor);
|
|
238
258
|
const byDependency = actor.kind === 'lane' && dependencyChange(actor, file, root, config);
|
|
239
259
|
if (entry && !granted && actor.kind !== 'lead' && !byDependency) {
|
|
260
|
+
recordRequest(root, actor, entry.approval, file, entry.why);
|
|
240
261
|
block(
|
|
241
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)}`,
|
|
242
263
|
);
|
package/hooks/hooks.json
CHANGED
|
@@ -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/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'}).`,
|