@warren-dean/code-kit 0.3.15 → 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.
@@ -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.15",
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.15",
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,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.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
+
23
+ ## 0.4.1 (2026-10-07)
24
+
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.
26
+
27
+ ## 0.4.0 (2026-10-07)
28
+
29
+ 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).
30
+
31
+ - **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.
32
+ - **`/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.
33
+ - **Refusal cards:** a code-kit refusal reads as a card with the rule, what to do, and Approve… where an approval would allow it.
34
+ - **`/approvals`** and **`/verify-branch`** print without a model call.
35
+ - **New in the CLI:** `code-kit stops` lists finish checks still failing, and `check --json` reports the config's state for the mod.
36
+ - A merge from the pane says so in its commit.
37
+ - `code-kit` with no command now prints its whole usage.
38
+
5
39
  ## 0.3.15 (2026-10-06)
6
40
 
7
41
  - **`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.
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 secrets;
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.
@@ -101,12 +101,13 @@ 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
 
107
108
  ## Install
108
109
 
109
- **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.
110
111
 
111
112
  **The plugin**, from this repository's marketplace:
112
113
 
@@ -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,7 +317,7 @@ 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 # validate and summarise
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
@@ -311,6 +329,8 @@ code-kit next [--base ref] [--json] # the step to take now, from the project'
311
329
  code-kit trace <path>... [--json] # what a file is for: requirements, lane, layer rules
312
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
313
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 sent-back <branch> [--reason "…"] # record a review's send-back: status shows the story as sent back until the branch moves on
314
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
315
335
  ```
316
336
 
@@ -320,27 +340,27 @@ code-kit approve <name>... --reason "…" [--lane name] [--delegated | --via pan
320
340
 
321
341
  `.claude/code-kit.json`. Globs use `*` (one segment), `**` (any depth), `**/` (zero or more folders), `?` and `{a,b}`. Patterns under `shell` are regular expressions.
322
342
 
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"]` |
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"]` |
344
364
 
345
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.
346
366
 
@@ -357,7 +377,7 @@ echo "Date formatting for ST-12" > .claude/approvals/web/dep-dayjs #
357
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.
358
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.
359
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.
360
- - **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.
361
381
 
362
382
  ### From chat
363
383
 
@@ -392,7 +412,7 @@ code-kit approve date-fns --lane web --delegated --reason "Date formatting for S
392
412
  ```
393
413
 
394
414
  - **Protected approvals:** the lead may grant those named in `protected`, and no others.
395
- - **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` is true. Packages from other ecosystems, and anything the registry can't confirm, go to the person.
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.
396
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.
397
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.
398
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
@@ -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 validate the config and summarise it
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
@@ -18,10 +18,12 @@
18
18
  // merge <branch> --delegated|--person [--into branch] merge a branch that passes verify: the lead
19
19
  // (approvals.delegate.merge) or the person (the code-kit pane)
20
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
22
+ // sent-back <branch> [--reason …] records that a review sent the branch back at its current commit
21
23
  // record approvals a person gave in chat (with approvals.lead on, the lead runs it)
22
24
  // --config <file> reads a draft (.claude/code-kit.draft.json) instead of .claude/code-kit.json.
23
25
  import { execFileSync } from 'node:child_process';
24
- import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
26
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
25
27
  import { tmpdir } from 'node:os';
26
28
  import { join, posix, resolve } from 'node:path';
27
29
  import { BASELINE_FILE, loadBaseline } from '../hooks/lib/baseline.mjs';
@@ -33,8 +35,10 @@ import { ADAPTERS, activeAdapters } from '../hooks/lib/adapters/index.mjs';
33
35
  import { nextStep } from '../hooks/lib/next.mjs';
34
36
  import { traceFile } from '../hooks/lib/trace.mjs';
35
37
  import { buildStatus } from '../hooks/lib/plan.mjs';
38
+ import { recordSentBack } from '../hooks/lib/reviews.mjs';
36
39
  import {
37
40
  APPROVAL_MINUTES,
41
+ approvalLifetime,
38
42
  currentBranch,
39
43
  PANE,
40
44
  approvalsInForce,
@@ -42,10 +46,11 @@ import {
42
46
  grantApprovals,
43
47
  openRequests,
44
48
  ownerOf,
49
+ reviewerOf,
45
50
  } from '../hooks/lib/rules.mjs';
46
51
  import { DEPENDENCY_APPROVAL, dependencyApproval } from '../hooks/lib/dependencies.mjs';
47
52
  import { dependencyRuleProblems, npmFacts, packageOf } from '../hooks/lib/registry.mjs';
48
- import { laneOfBranch, verifyBranch } from '../hooks/lib/verify.mjs';
53
+ import { heldTo, laneOfBranch, verifyBranch } from '../hooks/lib/verify.mjs';
49
54
 
50
55
  const out = (s) => process.stdout.write(`${s}\n`);
51
56
  // Piped into `head` and the like: stop quietly when the reader closes.
@@ -71,6 +76,7 @@ const reason = option('--reason');
71
76
  const laneName = option('--lane');
72
77
  const intoName = option('--into');
73
78
  const via = option('--via');
79
+ const sessionId = option('--session');
74
80
  const FLAGS = ['--write', '--no-checks', '--json', '--delegated', '--person'];
75
81
  const flag = (name) => args.includes(name);
76
82
  const writeBaseline = flag('--write');
@@ -92,7 +98,32 @@ function load(path = file) {
92
98
  return { raw, config: effectiveConfig(raw, '.') };
93
99
  }
94
100
 
101
+ /** The config as the mod reads it: whether it's there and valid, and the lead's and lanes' paths. */
102
+ function checkJson() {
103
+ const report = { file, exists: existsSync(resolve(file)), valid: false, problems: [] };
104
+ if (!report.exists) return (out(JSON.stringify(report, null, 2)), process.exit(1));
105
+ let raw;
106
+ try {
107
+ raw = JSON.parse(read(file));
108
+ } catch (e) {
109
+ report.problems = [e.message];
110
+ return (out(JSON.stringify(report, null, 2)), process.exit(1));
111
+ }
112
+ report.problems = validate(raw);
113
+ if (report.problems.length) return (out(JSON.stringify(report, null, 2)), process.exit(1));
114
+ const config = effectiveConfig(raw, '.');
115
+ report.valid = true;
116
+ report.lead = { paths: config.lead.paths };
117
+ report.lanes = Object.fromEntries(
118
+ Object.entries(config.lanes).map(([name, l]) => [name, { agent: l.agent, paths: l.paths }]),
119
+ );
120
+ report.adapters = config.adapters;
121
+ report.docs = config.docs ?? null;
122
+ out(JSON.stringify(report, null, 2));
123
+ }
124
+
95
125
  function check() {
126
+ if (flag('--json')) return checkJson();
96
127
  const { raw } = load();
97
128
  out(`${file} is valid.`);
98
129
  out(`Lead: ${raw.lead.paths.join(', ')}`);
@@ -108,7 +139,13 @@ function who(paths) {
108
139
  const { config } = load();
109
140
  for (const p of paths) {
110
141
  const layer = layerOf(p, config.layers);
111
- out(`${p}: ${ownerOf(p, config) ?? 'nobody'}${layer ? ` · layer ${layer.name}` : ''}`);
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}` : ''}`);
112
149
  }
113
150
  }
114
151
 
@@ -261,6 +298,27 @@ const VERIFY_GROUPS = {
261
298
  // merges, and only when `verify` passes on that branch, checks included. A person merges with git.
262
299
  // With --person, the person merges (the mod passes it for a press in its pane): the same verify first,
263
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
+
264
322
  function merge(branch) {
265
323
  const { config } = load();
266
324
  const byPerson = flag('--person');
@@ -270,14 +328,16 @@ function merge(branch) {
270
328
  );
271
329
  if (flag('--delegated') && byPerson)
272
330
  die("A merge is either the lead's (--delegated) or the person's (--person), not both.");
273
- if (!byPerson && !config.approvals.delegate?.merge)
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)
274
335
  die(
275
- 'This project doesn\'t delegate merges to the lead ("approvals.delegate.merge" isn\'t true). A person merges after review.',
336
+ `${into} doesn't delegate merges to the lead ("approvals.delegate.merge" isn't true in its ${file}). A person merges after review.`,
276
337
  );
277
- const into = intoName ?? config.branches.protected[0];
278
- if (!config.branches.protected.includes(into))
338
+ if (!target.branches.protected.includes(into))
279
339
  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.`);
340
+ if (target.branches.protected.includes(branch)) die(`${branch} is itself a protected branch.`);
281
341
  const root = resolve('.');
282
342
  const g = (cwd, ...a) =>
283
343
  execFileSync('git', ['-C', cwd, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
@@ -347,7 +407,7 @@ function merge(branch) {
347
407
  '-m',
348
408
  `Merge ${branch} into ${into}`,
349
409
  '-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'}.`,
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'}.`,
351
411
  branch,
352
412
  );
353
413
  } catch (e) {
@@ -361,7 +421,7 @@ function merge(branch) {
361
421
  );
362
422
  }
363
423
  out(
364
- `Merged ${branch} into ${into}: verify passed on ${changed.length} file(s)${lane ? `, held to the ${lane} lane` : ''}.`,
424
+ `Merged ${branch} into ${into}: verify passed on ${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}.`,
365
425
  );
366
426
  } finally {
367
427
  cleanup();
@@ -386,6 +446,51 @@ function requests() {
386
446
  );
387
447
  }
388
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
+
467
+ /** Finish checks still failing: the stop hook's last refusal for each session and agent. */
468
+ function stops() {
469
+ load();
470
+ const dir = resolve('.claude/state/stop-blocks');
471
+ const found = [];
472
+ for (const name of existsSync(dir) ? readdirSync(dir) : []) {
473
+ try {
474
+ const b = JSON.parse(readFileSync(join(dir, name), 'utf8'));
475
+ if (sessionId && b.session !== sessionId) continue;
476
+ found.push({
477
+ session: b.session ?? null,
478
+ agent: b.agent ?? null,
479
+ agentType: b.agentType ?? null,
480
+ title: String(b.message ?? '').split('\n')[0],
481
+ count: b.count ?? 1,
482
+ at: b.at ?? null,
483
+ });
484
+ } catch {
485
+ // a file the hook is still writing, or not one of its own
486
+ }
487
+ }
488
+ if (flag('--json')) return out(JSON.stringify(found, null, 2));
489
+ if (!found.length) return out('No finish check is failing.');
490
+ for (const s of found)
491
+ out(`${s.agentType ?? 'the lead'}${s.session ? ` (session ${s.session})` : ''}: ${s.title}`);
492
+ }
493
+
389
494
  function verify() {
390
495
  const { config } = load();
391
496
  const from = base(config, true);
@@ -418,7 +523,7 @@ function verify() {
418
523
  return;
419
524
  }
420
525
  out(
421
- `${changed.length} file(s) changed since ${from}${lane ? `, held to the ${lane} lane (branch ${branch})` : ''}.`,
526
+ `${changed.length} file(s) changed since ${from}${lane ? `, held to ${heldTo(lane)} (branch ${branch})` : ''}.`,
422
527
  );
423
528
  let count = 0;
424
529
  for (const [key, title] of Object.entries(VERIFY_GROUPS)) {
@@ -522,7 +627,10 @@ function adapters() {
522
627
  if (!active.includes(a.name)) continue;
523
628
  const c = a.config ?? {};
524
629
  if (c.lead?.length) out(` lead writes: ${c.lead.join(', ')}`);
525
- if (c.anyActor?.length) out(` anyone writes: ${c.anyActor.join(', ')}`);
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
+ );
526
634
  for (const p of c.protected ?? [])
527
635
  out(` protected: ${p.glob} (approval "${p.approval}": ${p.why})`);
528
636
  for (const b of a.shell?.block ?? []) out(` blocked command: /${b.pattern}/ — ${b.why}`);
@@ -537,7 +645,7 @@ function trace(paths) {
537
645
  for (const t of traces) {
538
646
  out(t.path);
539
647
  out(
540
- ` 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'}` : ''}`,
541
649
  );
542
650
  if (!t.requirements.length) out(' no requirements traced (no commit names a story)');
543
651
  for (const r of t.requirements)
@@ -607,7 +715,7 @@ async function approve(given) {
607
715
  const whom = laneName ? `the ${laneName} lane` : 'any agent';
608
716
  const renamed = given.flatMap((g, i) => (g === names[i] ? [] : [`${g} → ${names[i]}`]));
609
717
  out(
610
- `Approved ${names.join(', ')} for ${whom}, for ${APPROVAL_MINUTES} minutes${delegated ? ', within the delegated rules' : ''}: ${reason.trim()}${renamed.length ? `\n(${renamed.join(', ')})` : ''}`,
718
+ `Approved ${names.join(', ')} for ${whom}, ${approvalLifetime(names)}${delegated ? ', within the delegated rules' : ''}: ${reason.trim()}${renamed.length ? `\n(${renamed.join(', ')})` : ''}`,
611
719
  );
612
720
  }
613
721
 
@@ -616,6 +724,8 @@ const commands = {
616
724
  approve: () => (rest.length ? approve(rest) : usage()),
617
725
  merge: () => (rest.length === 1 ? merge(rest[0]) : usage()),
618
726
  requests,
727
+ stops,
728
+ 'sent-back': () => (rest.length === 1 ? sentBackCommand(rest[0]) : usage()),
619
729
  trace: () => (rest.length ? trace(rest) : usage()),
620
730
  adapters,
621
731
  next,
@@ -632,8 +742,9 @@ function usage() {
632
742
  'Usage: code-kit check | who <path>... | unowned | diff | baseline [--write] | graph [--depth N]\n' +
633
743
  ' | verify [--base ref] [--branch name] [--no-checks] | status [--base ref] [--json]\n' +
634
744
  ' | next [--base ref] [--json] | adapters | trace <path>... [--json]\n' +
635
- ' | approve <name>... --reason "…" [--lane name] [--config file]',
636
- ' | merge <branch> --delegated|--person [--into branch] | requests [--json]',
745
+ ' | approve <name>... --reason "…" [--lane name] [--config file]\n' +
746
+ ' | merge <branch> --delegated|--person [--into branch] | requests [--json]\n' +
747
+ ' | stops [--session id] [--json] | sent-back <branch> [--reason "…"]',
637
748
  );
638
749
  }
639
750
  await (commands[command] ?? usage)();