@warren-dean/code-kit 0.14.0 → 0.15.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.
@@ -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.14.0",
12
+ "version": "0.15.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.14.0",
3
+ "version": "0.15.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,20 @@
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.15.1 (2026-10-16)
6
+
7
+ Lanes stop less often to wait for you.
8
+
9
+ - A lane reading `.claude/code-kit.json` with an inline script, such as `python3 -c "json.load(open(...))"`, no longer waits for your approval. The bash guard used to count any Python command that named a kit file as a change to the kit, so each lane that read its spec folders this way filed a kit request and stopped: 11 of one project's 16 approval requests. An inline Python or Node script is now judged by its code, and only one that writes the kit, or whose output is redirected into it, is refused. A Python script run from a file still counts as a change, and the check after each shell call still catches any real write. The spec-check skill tells lanes to open the config with the Read tool.
10
+ - A call Claude Code refused that later goes through, the same tool from the same agent (for a shell command, the same command), leaves the band. Before, every refusal stayed in the band until you pressed Done it or Not now, even after a retry had worked, so the band could show calls refused that no longer needed you.
11
+
12
+ ## 0.15.0 (2026-10-16)
13
+
14
+ The lane agents can run on a cheaper model than the lead.
15
+
16
+ - `harness.agents.lanes.model` sets the model each lane agent starts on, such as `sonnet`. The mod sets it as the lead dispatches each lane agent, so the lead, the background reviewer and the security reviewer keep your session's model. A model the lead's own call names still wins, and so does a `model:` line in a lane's agent file. Leave the setting out and lane agents start on the session's model, as before.
17
+ - `/code-kit:init` asks which model the lanes should use and recommends Sonnet: each lane agent builds one story inside its own paths, and the hooks, `verify` and the lead's review catch what it gets wrong. `/harness` has the setting as a model picker, and `code-kit settings set agents.lanes.model <model>` sets it from the command line.
18
+
5
19
  ## 0.14.0 (2026-10-16)
6
20
 
7
21
  The lead loop stays in one session, listens to the lead before prompting it again, and asks your choices in Claude Code's own question dialog.
package/README.md CHANGED
@@ -361,7 +361,7 @@ The loop keeps the build moving between your prompts, under `harness.autonomy`.
361
361
  ### Other views
362
362
 
363
363
  - **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.
364
- - **`/harness`** opens the harness settings: how the lead loop acts, how long a call waiting on your approval is held, when a stalled lane is nudged and restarted, the reviewer agent and its model, the use at which background agents pause, and whether agents draft bug reports for code-kit. Changing one asks your reason, writes `.claude/code-kit.json` and logs a `kit` approval. Lanes, paths and rules still change through `/code-kit:init`.
364
+ - **`/harness`** opens the harness settings: how the lead loop acts, how long a call waiting on your approval is held, when a stalled lane is nudged and restarted, the reviewer agent and its model, the lane agents' model, the use at which background agents pause, and whether agents draft bug reports for code-kit. Changing one asks your reason, writes `.claude/code-kit.json` and logs a `kit` approval. Lanes, paths and rules still change through `/code-kit:init`.
365
365
  - **`/approvals`** lists the approvals waiting (one per approval and lane, with every file it covers), the approvals in force with minutes left, and every approval given, newest first: who gave it (you, or the lead within your delegated rules), its reason, and the files and branches it covered, merged or not. A request stops waiting once an approval is given for it, or its file is committed by someone who could; a read-only agent such as the reviewer never asks.
366
366
  - **`/verify-branch`** runs `code-kit verify` on this branch. Neither it nor `/approvals` calls the model.
367
367
 
@@ -473,33 +473,33 @@ code-kit approve <name>... --reason "…" [--lane name] [--delegated | --via pan
473
473
 
474
474
  `.claude/code-kit.json`. Globs use `*` (one segment), `**` (any depth), `**/` (zero or more folders), `?` and `{a,b}`. Patterns under `shell` are regular expressions.
475
475
 
476
- | Field | Required | What it is |
477
- | ------------------------------ | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
478
- | `version` | yes | `1` |
479
- | `docs.specs` | no | Folder of specs. With it, a lane needs a spec-check report before writing code on a branch |
480
- | `docs.principles`, `docs.plan` | no | The engineering principles and the implementation plan |
481
- | `docs.decisions` | no | Where agreed intents are recorded as numbered decision records (`code-kit intend record`); default `docs/decisions` |
482
- | `lead.paths` | yes | What the main session writes. `.claude/**` is always added. |
483
- | `lead.outside` | no | Paths outside the repository the lead may write, e.g. `../shared-lib/**`. Claude's memory and scratchpad are always included. |
484
- | `anyActor` | no | Paths every writer may change (e.g. the lockfile), read-only agents included. `.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. |
485
- | `lanes.<name>` | yes (may be `{}`) | `{ agent, paths, exclude?, owners? }`: the agent name maps a subagent to its lane; `owners` (GitHub handles) are the people whose sessions work it, in a team (needs `team`). A worktree with a `.lane` file holding the lane name makes the main session that lane. |
486
- | `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`. |
487
- | `designGate` | no | `{ register, screens: [{ glob, not? }], howTo }`: screen files wait until the register (`{ "screens": [{ "file": … }] }`) lists them. Init sets it for every lane that owns screens; `/code-kit:design` adds approved designs |
488
- | `layers[]` | no | `{ name, paths, mayImport, denyPackages? }`: the first layer whose paths match a file owns it. A layer may always import itself. |
489
- | `importAliases` | no | `{ "@app/domain": "packages/domain/src" }`: resolves non-relative imports to repository paths |
490
- | `shell.block[]` | no | `{ pattern, why, person?, command? }`: never run. `person: true` marks a person's act, which the lead may do for them with `approvals.lead` on. `command: true` matches the pattern from the start of each command the line runs, so a heredoc or quoted text that mentions it isn't refused |
491
- | `shell.restricted[]` | no | `{ pattern, lanes, why }`: only the lead and these lanes |
492
- | `postEdit[]` | no | `{ files, exclude?, run }`: after each edit of a matching file. `{file}` is the file. Exit 127 (tool not installed) is skipped. |
493
- | `tests[]` | no | Globs naming the test files, for the weakened-test and fix rules. Default: the usual conventions (`*.test.*`, `*.spec.*`, `__tests__/`, `test/`, `tests/`, `e2e/`, `*_test.go`, `test_*.py`). |
494
- | `regression.prepare` | no | A command run in a fresh checkout before a fix's tests run there without the fix, such as `npm ci`. Without it, the checkout's `node_modules` and virtualenvs are linked in. |
495
- | `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 the check, which `verify` and a merge list as not run). |
496
- | `adapters` | no | `{ "<adapter>": false }` switches off an adapter that would otherwise be detected; `true` says the project wants that tool, so until it's set up here `next` gives its setup before any code. See [Adapters](#adapters). |
497
- | `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). |
498
- | `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). |
499
- | `setup` | no | The version of init's setup the project has (3 now). Without it, or lower, `next` names what the project is missing (the design gate, design stories before screens, a QA lane, a security reviewer) and starts no new story until re-running init adds it |
500
- | `team` | no | Preview. For several people in one repository: `{ techLead, staleDays?, intents?, board?, provider? }`. `techLead` is the GitHub handle of the person who owns the plan, specs and contracts; `staleDays` is how many working days without a push make a claim stale (default 3); `intents` is the discussion category intents go in (default `Intents`; create it once in the repository's Settings › Discussions); `board` is `{ owner, number }`, a GitHub Project the plan is mirrored onto; `provider` is `"github"` (default) or `{ command, fallback? }` for another team provider, such as the code-kit teams hub; with `fallback: "github"`, claims go through GitHub whenever that provider can't be reached. See docs/specs/16-teams.md. |
501
- | `branches.protected` | no | Default `["main", "master"]` |
502
- | `harness` | no | What the mod's harness does in the session: `autonomy` (`autonomous`, `propose` or `off`; default `autonomous`), `hold.minutes` (how long a call waiting on your approval is held, 0 to 10; default 2), `stall.nudgeMinutes` and `stall.restartMinutes` (default 5 and 10), `stall.maxRestarts` (default 2), `agents.reviewer.on` (default `false`) and `agents.reviewer.model` (default the session's), `background.pauseAtPercent` (default 80) and `report.on` (agents draft bug reports for code-kit, which you send or discard; default `false`). Change them with `/harness` or `code-kit settings set`. |
476
+ | Field | Required | What it is |
477
+ | ------------------------------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
478
+ | `version` | yes | `1` |
479
+ | `docs.specs` | no | Folder of specs. With it, a lane needs a spec-check report before writing code on a branch |
480
+ | `docs.principles`, `docs.plan` | no | The engineering principles and the implementation plan |
481
+ | `docs.decisions` | no | Where agreed intents are recorded as numbered decision records (`code-kit intend record`); default `docs/decisions` |
482
+ | `lead.paths` | yes | What the main session writes. `.claude/**` is always added. |
483
+ | `lead.outside` | no | Paths outside the repository the lead may write, e.g. `../shared-lib/**`. Claude's memory and scratchpad are always included. |
484
+ | `anyActor` | no | Paths every writer may change (e.g. the lockfile), read-only agents included. `.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. |
485
+ | `lanes.<name>` | yes (may be `{}`) | `{ agent, paths, exclude?, owners? }`: the agent name maps a subagent to its lane; `owners` (GitHub handles) are the people whose sessions work it, in a team (needs `team`). A worktree with a `.lane` file holding the lane name makes the main session that lane. |
486
+ | `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`. |
487
+ | `designGate` | no | `{ register, screens: [{ glob, not? }], howTo }`: screen files wait until the register (`{ "screens": [{ "file": … }] }`) lists them. Init sets it for every lane that owns screens; `/code-kit:design` adds approved designs |
488
+ | `layers[]` | no | `{ name, paths, mayImport, denyPackages? }`: the first layer whose paths match a file owns it. A layer may always import itself. |
489
+ | `importAliases` | no | `{ "@app/domain": "packages/domain/src" }`: resolves non-relative imports to repository paths |
490
+ | `shell.block[]` | no | `{ pattern, why, person?, command? }`: never run. `person: true` marks a person's act, which the lead may do for them with `approvals.lead` on. `command: true` matches the pattern from the start of each command the line runs, so a heredoc or quoted text that mentions it isn't refused |
491
+ | `shell.restricted[]` | no | `{ pattern, lanes, why }`: only the lead and these lanes |
492
+ | `postEdit[]` | no | `{ files, exclude?, run }`: after each edit of a matching file. `{file}` is the file. Exit 127 (tool not installed) is skipped. |
493
+ | `tests[]` | no | Globs naming the test files, for the weakened-test and fix rules. Default: the usual conventions (`*.test.*`, `*.spec.*`, `__tests__/`, `test/`, `tests/`, `e2e/`, `*_test.go`, `test_*.py`). |
494
+ | `regression.prepare` | no | A command run in a fresh checkout before a fix's tests run there without the fix, such as `npm ci`. Without it, the checkout's `node_modules` and virtualenvs are linked in. |
495
+ | `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 the check, which `verify` and a merge list as not run). |
496
+ | `adapters` | no | `{ "<adapter>": false }` switches off an adapter that would otherwise be detected; `true` says the project wants that tool, so until it's set up here `next` gives its setup before any code. See [Adapters](#adapters). |
497
+ | `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). |
498
+ | `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). |
499
+ | `setup` | no | The version of init's setup the project has (3 now). Without it, or lower, `next` names what the project is missing (the design gate, design stories before screens, a QA lane, a security reviewer) and starts no new story until re-running init adds it |
500
+ | `team` | no | Preview. For several people in one repository: `{ techLead, staleDays?, intents?, board?, provider? }`. `techLead` is the GitHub handle of the person who owns the plan, specs and contracts; `staleDays` is how many working days without a push make a claim stale (default 3); `intents` is the discussion category intents go in (default `Intents`; create it once in the repository's Settings › Discussions); `board` is `{ owner, number }`, a GitHub Project the plan is mirrored onto; `provider` is `"github"` (default) or `{ command, fallback? }` for another team provider, such as the code-kit teams hub; with `fallback: "github"`, claims go through GitHub whenever that provider can't be reached. See docs/specs/16-teams.md. |
501
+ | `branches.protected` | no | Default `["main", "master"]` |
502
+ | `harness` | no | What the mod's harness does in the session: `autonomy` (`autonomous`, `propose` or `off`; default `autonomous`), `hold.minutes` (how long a call waiting on your approval is held, 0 to 10; default 2), `stall.nudgeMinutes` and `stall.restartMinutes` (default 5 and 10), `stall.maxRestarts` (default 2), `agents.reviewer.on` (default `false`) and `agents.reviewer.model` (default the session's), `agents.lanes.model` (the model each lane agent starts on, unless the lead's call or the lane's agent file names one; default the session's), `background.pauseAtPercent` (default 80) and `report.on` (agents draft bug reports for code-kit, which you send or discard; default `false`). Change them with `/harness` or `code-kit settings set`. |
503
503
 
504
504
  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.
505
505
 
@@ -368,8 +368,21 @@ if (team) {
368
368
  const KIT_PATH = /\.claude\/(hooks|agents|skills|settings[^/]*\.json|README\.md|code-kit\.json)/;
369
369
  // a real redirect into a file; `2>&1` and `>/dev/null` are not writes
370
370
  const WRITES =
371
- /((?<![0-9&])>>?\s*(?!&|\/dev\/null)\S|\btee\b|\bsed\s+-i|\bperl\s+-i|\bmv\b|\bcp\b|\brm\b|\bpython3?\b|\bnode\s+-e|\btruncate\b|\bchmod\b|\bln\b)/;
372
- const kitWrite = commandsOf(cmd).some((c) => KIT_PATH.test(c.text) && WRITES.test(c.text));
371
+ /((?<![0-9&])>>?\s*(?!&|\/dev\/null)\S|\btee\b|\bsed\s+-i|\bperl\s+-i|\bmv\b|\bcp\b|\brm\b|\btruncate\b|\bchmod\b|\bln\b)/;
372
+ // An inline script (`python3 -c`, `node -e`) is judged by its code: lanes read the config this way,
373
+ // and a read isn't a change to the kit. A Python script run from a file can't be read here, so it counts.
374
+ const SCRIPT = /\bpython3?\b/;
375
+ const INLINE =
376
+ /(?:^|\s)(?:python3?\s+(?:-\w+\s+)*-c|node\s+(?:-\w+\s+)*(?:-e|--eval|-p|--print))\s/;
377
+ const SCRIPT_WRITES =
378
+ /\bopen(?:Sync)?\s*\([^)]*['"][rwax]*[wax+][rwax+b]*['"]|\.write_(?:text|bytes)\b|\.(?:unlink|rename|touch|rmdir|chmod|symlink_to)\s*\(|\bos\.(?:remove|unlink|rename|replace|rmdir|chmod|symlink|system|popen)\b|\bshutil\.|\bsubprocess\.|\b(?:writeFile|appendFile|rm|rmdir|unlink|rename|copyFile|cp|chmod|truncate|symlink|createWriteStream)(?:Sync)?\s*\(|\bexecSync\b|\bspawnSync\b|\bchild_process\b/;
379
+ const writesKit = ({ text, code }) => {
380
+ if (!KIT_PATH.test(text)) return false;
381
+ // the script's own text is quoted, so a `>` in it isn't a redirect; one outside it still is
382
+ if (INLINE.test(text)) return WRITES.test(code) || SCRIPT_WRITES.test(text);
383
+ return WRITES.test(text) || SCRIPT.test(text);
384
+ };
385
+ const kitWrite = commandsOf(cmd).some(writesKit);
373
386
  if (kitWrite && actor.kind !== 'lead' && !approval(root, 'kit', actor)) {
374
387
  recordRequest(
375
388
  root,
@@ -51,6 +51,15 @@ export const SETTINGS = {
51
51
  : `must be a model (${MODELS.join(', ')}, or an id), or null for the session's`,
52
52
  about: "The reviewer's model; none means the session's",
53
53
  },
54
+ 'agents.lanes.model': {
55
+ default: null,
56
+ problem: (v) =>
57
+ v === null || (typeof v === 'string' && v.length > 0)
58
+ ? null
59
+ : `must be a model (${MODELS.join(', ')}, or an id), or null for the session's`,
60
+ about:
61
+ "The lane agents' model, set as each is dispatched; none means the session's. A lane's agent file with its own model keeps it",
62
+ },
54
63
  'background.pauseAtPercent': {
55
64
  default: 80,
56
65
  problem: (v) =>
@@ -222,3 +222,20 @@ export const answeredPrompt = (question, answer) => {
222
222
  ? `The person answered what you asked ("${question}"): ${said} Carry on with it.`
223
223
  : `The person says: ${said} Carry on with it.`;
224
224
  };
225
+
226
+ /** SET-4: the `model:` an agent file's frontmatter names, or null. */
227
+ export function agentFileModel(text) {
228
+ const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(text ?? ''))?.[1] ?? '';
229
+ return /^model:\s*['"]?([^'"\s]+)['"]?\s*$/m.exec(front)?.[1] ?? null;
230
+ }
231
+
232
+ /**
233
+ * SET-4: the model to start a lane agent on, or null to leave the spawn as it is: a model the lead's
234
+ * call names wins, then the lane agent file's own (other than `inherit`), then `harness.agents.lanes.model`.
235
+ */
236
+ export function laneModel({ type, model = null, lanes = {}, setting = null, fileModel = null }) {
237
+ if (model || !setting) return null;
238
+ if (!Object.values(lanes).some((l) => l.agent === type)) return null;
239
+ if (fileModel && fileModel !== 'inherit') return null;
240
+ return setting;
241
+ }
@@ -51,8 +51,10 @@ import {
51
51
  toolLine,
52
52
  } from './panes.mjs';
53
53
  import {
54
+ agentFileModel,
54
55
  answeredPrompt,
55
56
  designHandedOver,
57
+ laneModel,
56
58
  leadPrompt,
57
59
  nudgeText,
58
60
  outcomePrompt,
@@ -1682,6 +1684,7 @@ export function register(on) {
1682
1684
  const lane = e.agent_id ? activity.get(e.agent_id)?.lane : null;
1683
1685
  denied.set(e.tool_use_id ?? `${e.tool_name}-${denied.size}`, {
1684
1686
  id: e.tool_use_id ?? `${e.tool_name}-${denied.size}`,
1687
+ agentId: e.agent_id ?? null,
1685
1688
  who: lane ? `the ${lane} lane` : (e.agent_type ?? (e.agent_id ? 'an agent' : 'the lead')),
1686
1689
  tool: e.tool_name,
1687
1690
  command,
@@ -1731,6 +1734,19 @@ export function register(on) {
1731
1734
  try {
1732
1735
  const res = await next(e);
1733
1736
  const text = res?.deny ?? (res?.isError ? (res.text ?? String(res.result ?? '')) : null);
1737
+ // PERM-1: a refused call that later goes through (retried, or allowed) no longer waits for the person.
1738
+ if (!text && denied.size) {
1739
+ const command = e.tool === 'Bash' ? (e.command ?? null) : null;
1740
+ const before = denied.size;
1741
+ for (const [id, d] of denied)
1742
+ if (
1743
+ d.tool === e.tool &&
1744
+ d.agentId === (e.agentId ?? null) &&
1745
+ (!d.command || d.command === command)
1746
+ )
1747
+ denied.delete(id);
1748
+ if (denied.size < before) $.ui.invalidate('ui.render');
1749
+ }
1734
1750
  if (text && refusalCard(text)) {
1735
1751
  refusals.set(e.tool_use_id, text);
1736
1752
  if (refusals.size > REFUSALS_KEPT) refusals.delete(refusals.keys().next().value);
@@ -1828,6 +1844,26 @@ export function register(on) {
1828
1844
  });
1829
1845
  });
1830
1846
 
1847
+ // SET-4: a lane agent starts on the lane model the person set, unless the lead's call names one or
1848
+ // the lane's agent file sets its own.
1849
+ on('agent.spawn', async ($, e, next) => {
1850
+ const setting = harness().agents.lanes.model;
1851
+ const lanes = model.check?.lanes ?? {};
1852
+ if (!setting || e.model || !Object.values(lanes).some((l) => l.agent === e.subagentType))
1853
+ return next(e);
1854
+ const file = where
1855
+ ? await $.fs.read(`${where.cwd}/.claude/agents/${e.subagentType}.md`).catch(() => null)
1856
+ : null;
1857
+ const chosen = laneModel({
1858
+ type: e.subagentType,
1859
+ model: e.model,
1860
+ lanes,
1861
+ setting,
1862
+ fileModel: typeof file === 'string' ? agentFileModel(file) : null,
1863
+ });
1864
+ return next(chosen ? { ...e, model: chosen } : e);
1865
+ }).catch(($, e, next) => next(e));
1866
+
1831
1867
  // USE-1: each model request's usage, as the request ends; the lead's requests carry no agentId.
1832
1868
  on('turn.step', async function* ($, e, next) {
1833
1869
  const res = yield* next(e);
@@ -156,6 +156,8 @@ function project() {
156
156
  leadClaims: 0,
157
157
  asked: [] as { question: string; options: string[]; header?: string }[], // dialogs the mod opened
158
158
  typed: null as string | null, // what the person types under the dialog's Other, if anything
159
+ agentFiles: {} as Record<string, string>, // .claude/agents/<name>.md, by path
160
+ spawned: [] as any[], // agent.spawn as it reached Claude Code beneath the mod
159
161
  map: null as any, // `code-kit map <story> --json`
160
162
  trail: null as any, // `code-kit trail --json`
161
163
  journal: { events: [] as any[] }, // `code-kit journal --json`
@@ -397,6 +399,14 @@ function stub(on: any, w: World) {
397
399
  on('turn.start', ($: any, e: any) => e);
398
400
  on('turn.complete', () => ({ text: '' }));
399
401
  on('session.end', ($: any, e: any) => ({ sessionId: e.sessionId }));
402
+ on('fs.read', ($: any, e: any) => {
403
+ if (w.agentFiles[e.path] === undefined) throw new Error(`ENOENT: ${e.path}`);
404
+ return { value: w.agentFiles[e.path] };
405
+ });
406
+ on('agent.spawn', ($: any, e: any) => {
407
+ w.spawned.push(e);
408
+ return { model: e.model ?? 'claude-opus-5-5', agentId: `a-${w.spawned.length}` };
409
+ });
400
410
  on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: w.limits } }));
401
411
  on('session.measure', ($: any, e: any) => ({ changed: e.changed }));
402
412
  // A model request, answered with the usage the test gave it.
@@ -1697,6 +1707,38 @@ test("PERM-1 a call Claude Code's own permission check refused waits for the per
1697
1707
  await after.unmount();
1698
1708
  });
1699
1709
 
1710
+ test('PERM-1 a refused call that later goes through no longer waits for the person', async ($, on) => {
1711
+ const w = project();
1712
+ await start($, on, w);
1713
+ const refused = (id: string, command: string) =>
1714
+ $.classic.PermissionDenied({
1715
+ tool_name: 'Bash',
1716
+ tool_input: { command },
1717
+ tool_use_id: id,
1718
+ reason: 'Permission for this action was denied by the Claude Code auto mode classifier.',
1719
+ });
1720
+ await refused('tu-1', 'pnpm db:generate');
1721
+ await refused('tu-2', 'pnpm db:migrate');
1722
+ // another command going through leaves both waiting
1723
+ await $.tool.call({ tool: 'Bash', tool_use_id: 'tu-3', command: 'pnpm test' } as any);
1724
+ let band = await bandUi($);
1725
+ expect(await band.find({ type: 'Text', text: /^2 calls refused by Claude Code/ })).toBeDefined();
1726
+ await band.unmount();
1727
+ // the lead retries one and it goes through: only the other still waits
1728
+ await $.tool.call({ tool: 'Bash', tool_use_id: 'tu-4', command: 'pnpm db:generate' } as any);
1729
+ band = await bandUi($);
1730
+ expect(
1731
+ await band.find({ type: 'Text', text: "Claude Code refused the lead's `pnpm db:migrate`" }),
1732
+ ).toBeDefined();
1733
+ await band.unmount();
1734
+ await $.tool.call({ tool: 'Bash', tool_use_id: 'tu-5', command: 'pnpm db:migrate' } as any);
1735
+ band = await bandUi($);
1736
+ expect(
1737
+ await band.find({ type: 'Text', text: /Claude Code refused|refused by Claude Code/ }),
1738
+ ).toBeUndefined();
1739
+ await band.unmount();
1740
+ });
1741
+
1700
1742
  test('CARD-8 two approvals waiting are two decisions, each approved or dropped on its own', async ($, on) => {
1701
1743
  const w = project();
1702
1744
  w.requests = [
@@ -2622,6 +2664,45 @@ test('DECIDE-4 nothing is asked in a session another one runs the loop for', asy
2622
2664
  expect(w.asked).toEqual([]);
2623
2665
  });
2624
2666
 
2667
+ // --- the lane agents' model (SET-4) ---------------------------------------------------------------
2668
+
2669
+ const dispatch = ($: any, subagentType: string, model?: string) =>
2670
+ $.agent.spawn({
2671
+ prompt: 'ST-9 pricing',
2672
+ description: 'ST-9 pricing',
2673
+ subagentType,
2674
+ ...(model ? { model } : {}),
2675
+ });
2676
+
2677
+ test("SET-4 a lane agent starts on the lane model set; the lead's own choice, an agent file's model and other agents are left alone", async ($, on) => {
2678
+ const w = project();
2679
+ (w.check as any).harness = { agents: { lanes: { model: 'sonnet' } } };
2680
+ w.agentFiles['/work/.claude/agents/web-engineer.md'] =
2681
+ '---\nname: web-engineer\nmodel: opus\n---\nYou are the web engineer.\n';
2682
+ w.agentFiles['/work/.claude/agents/api-engineer.md'] =
2683
+ '---\nname: api-engineer\nmodel: inherit\n---\n';
2684
+ await start($, on, w);
2685
+ await dispatch($, 'core-engineer');
2686
+ await dispatch($, 'api-engineer');
2687
+ await dispatch($, 'core-engineer', 'haiku');
2688
+ await dispatch($, 'web-engineer');
2689
+ await dispatch($, 'general-purpose');
2690
+ expect(w.spawned.map((e) => [e.subagentType, e.model ?? null])).toEqual([
2691
+ ['core-engineer', 'sonnet'],
2692
+ ['api-engineer', 'sonnet'],
2693
+ ['core-engineer', 'haiku'],
2694
+ ['web-engineer', null],
2695
+ ['general-purpose', null],
2696
+ ]);
2697
+ });
2698
+
2699
+ test('SET-4 with no lane model set, lane agents start as Claude Code decides', async ($, on) => {
2700
+ const w = project();
2701
+ await start($, on, w);
2702
+ await dispatch($, 'core-engineer');
2703
+ expect(w.spawned[0].model ?? null).toBe(null);
2704
+ });
2705
+
2625
2706
  // --- the reviewer -----------------------------------------------------------------------------------
2626
2707
 
2627
2708
  /** ST-4 finished on web/st-4, the reviewer on. */
@@ -1496,6 +1496,7 @@ export const SETTINGS_ID = 'code-kit-settings';
1496
1496
  const CHOICES = {
1497
1497
  autonomy: ['autonomous', 'propose', 'off'],
1498
1498
  'agents.reviewer.model': ['', 'haiku', 'sonnet', 'opus'],
1499
+ 'agents.lanes.model': ['', 'haiku', 'sonnet', 'opus'],
1499
1500
  };
1500
1501
  const shown = (v) =>
1501
1502
  v === null || v === '' ? "the session's" : v === true ? 'on' : v === false ? 'off' : String(v);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warren-dean/code-kit",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "description": "Keeps AI coding agents inside your project's rules: lanes, layer rules, protected paths and proof before finish, enforced by Claude Code hooks and in CI",
5
5
  "license": "MIT",
6
6
  "author": "Warren Langeveldt",
@@ -74,6 +74,7 @@ Write `.claude/code-kit.draft.json`. Follow `${CLAUDE_PLUGIN_ROOT}/README.md`
74
74
  - **`postEdit`:** the project's existing per-file linters and formatters, with `{file}`.
75
75
  - **`checks`:** the typecheck, test, architecture and database commands, each scoped by `files`. Only include commands that pass on the current code, or ones the person chose to keep.
76
76
  - **`harness.autonomy`:** ask the person how hands-off they want the build. With the code-kit mod (Claude Code 2.1.287 or later), once the plan has stories, a lead loop can keep the work moving between their prompts: it prompts the lead to dispatch ready stories, review finished ones and merge them where merges are delegated. Offer the three choices in plain words: `autonomous` (it keeps going on its own; they can pause it from the band), `propose` (each step waits in the band for their Go), or `off`. Put their answer in the draft as `"harness": { "autonomy": "…" }`. If they'd rather decide later, leave it out: the loop then asks them in the band before its first step. With `autonomous`, the lead owns delivery, so also draft `approvals.delegate`: `"merge": true` (the lead merges a reviewed branch once every check passes) and `"dependencies": {}` (it approves a new package within the default limits on licence, popularity and age), so neither comes to the person. Say these are the limits; anything outside them still comes to them as a decision.
77
+ - **`harness.agents.lanes.model`:** ask which model the lane agents run on. Each lane agent builds one story inside its own paths, with its requirements and acceptance criteria in its brief, and the hooks, `verify` and the lead's review catch what it gets wrong, so Sonnet usually does the work well for much less than the session's model; recommend `sonnet`, and say the lead, the reviewer and the security reviewer keep the session's model. The mod sets it as each lane agent starts; a lane's agent file with its own `model:` keeps that. Put their answer in `harness` as `"agents": { "lanes": { "model": "sonnet" } }`, or leave it out for the session's model.
77
78
  - **`harness.report.on`:** ask whether agents may draft bug reports for code-kit itself when code-kit gets in their way (a refusal that shouldn't have happened, a crash). Each report holds only code-kit's own facts (its version, the rule, what it said, the command), never the project's code, paths or data, and waits in their Decisions tab: they read it and send it to code-kit's public GitHub under their own account, or discard it. Off unless they say yes; put `"report": { "on": true }` in `harness` if they do.
78
79
  - **`approvals.lead`:** ask the person whether they'll ever approve from a phone or another chat-only client, where a `!` command arrives as plain text. If so, set it to `true`: the lead then records approvals they give in chat (`code-kit approve`), and runs the `shell.block` commands marked `person: true` when they say so. Mark a `shell.block` command `person: true` only if the person wants to be able to have the lead run it on their say-so. Leave the setting out if the lead will run unattended.
79
80
 
@@ -119,7 +120,7 @@ Show the person:
119
120
  1. the layers and what each may depend on, and for brownfield the existing violations the baseline will record;
120
121
  2. the lanes, their agents and their paths, and the security reviewer (or why it's left out);
121
122
  3. protected paths, blocked and restricted commands, and checks (including any left out because they fail today);
122
- and whether the lead may record approvals given in chat (`approvals.lead`), how hands-off the build is (`harness.autonomy`), and whether agents may draft bug reports for code-kit (`harness.report.on`);
123
+ and whether the lead may record approvals given in chat (`approvals.lead`), how hands-off the build is (`harness.autonomy`), the lane agents' model (`harness.agents.lanes.model`), and whether agents may draft bug reports for code-kit (`harness.report.on`);
123
124
  4. the project skills, and which lane agents use each;
124
125
  5. the active adapters and what each adds, from `adapters`. A companion tool that's installed but not set up here (its init skill is in your skills list, for example `/context-graph:init` with no `.ctx/` folder): add `"adapters": { "<name>": true }` to the draft and say it will be set up before any code is written;
125
126
  6. every **question**: where the docs or the code are silent, ambiguous or contradictory. Don't guess; a wrong owner or layer blocks real work.
@@ -5,7 +5,7 @@ description: Check a task against the project's specs before implementing it, an
5
5
 
6
6
  # Spec check
7
7
 
8
- The project's specs are in the folder named by `docs.specs` in `.claude/code-kit.json`. The engineering principles are in `docs.principles`, and the plan in `docs.plan`.
8
+ The project's specs are in the folder named by `docs.specs` in `.claude/code-kit.json`. The engineering principles are in `docs.principles`, and the plan in `docs.plan`. Open the config with the Read tool. A shell script that reads it works too, but one that could write to it waits for a person's approval.
9
9
 
10
10
  ## Steps
11
11