@warren-dean/code-kit 0.5.0 → 0.6.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.5.0",
12
+ "version": "0.6.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.5.0",
3
+ "version": "0.6.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,19 @@
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.6.0 (2026-10-10)
6
+
7
+ The first parts of the harness, in the mod (Claude Code 2.1.287 or later):
8
+
9
+ - **Harness settings.** A new `harness` section in `.claude/code-kit.json` holds how the lead loop acts (`autonomy`), how long a call waiting on your approval is held, when a stalled lane is nudged and restarted, the reviewer agent and its model, and the plan use at which background agents pause. `code-kit check` validates it; a wrong value gives way to its default.
10
+ - **`code-kit settings`** lists the settings in force; `settings set <key> <value> --reason "…"` changes one, as the person, and logs it as a `kit` approval. Agents are refused.
11
+ - **`/harness`** opens the settings in the session. A change asks your reason before anything is written, and the approval log records it as made in the pane.
12
+ - **Usage per lane.** The mod counts each model request's tokens against the agent that made it, its story and its lane. The Lanes pane shows tokens per lane and story, the background agents' share, and the plan's 5-hour use as a bar.
13
+ - **Outliers in the band:** a story using more than three times the median of the stories finished this session (once three have) is flagged, as "ST-7 has used 3.4× the usual". Nothing is stopped.
14
+ - **The background pause:** the band says when the plan's 5-hour use passes `harness.background.pauseAtPercent` (80% unless set). The background agents that will honour it come in a later release.
15
+ - **Refusals your approval would allow** (new packages, protected files, kit edits) are recognised, including a commit refused for a protected file, ready for the hold that comes next.
16
+ - `code-kit check --json` carries the harness settings in force.
17
+
5
18
  ## 0.5.0 (2026-10-07)
6
19
 
7
20
  Fixes for gaps found running code-kit under an autonomous lead:
package/README.md CHANGED
@@ -269,15 +269,18 @@ With Claude Code 2.1.287 or later, code-kit's mod shows the project in the sessi
269
269
  - **The band** sits above the prompt whenever something waits for you, and is gone when nothing does. It shows:
270
270
  - approvals agents have asked for: **Approve…** opens a confirmation with a reason prefilled from the request, which you keep or rewrite;
271
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.
272
+ - a finish check failing in this session: **Lanes** opens the pane;
273
+ - a story using more than three times the median of the stories finished this session (once three have finished): **Lanes** opens the pane;
274
+ - background agents paused, once the plan’s 5-hour use passes `harness.background.pauseAtPercent` (80% unless set).
273
275
 
274
276
  Each button has a digit: type it in an empty prompt to press it.
275
277
 
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.
278
+ - **`/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. Beneath them, the session’s usage: tokens per lane and story, the background agents’ share, and the plan’s 5-hour use as a bar. 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
279
  - **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.
280
+ - **`/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, and the use at which background agents pause. 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`.
278
281
  - **`/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
282
 
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`.
283
+ 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. A settings change is logged as made in the pane. These happen only on your press: the hooks refuse `approve --via pane`, `merge --person` and `settings set` 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
284
 
282
285
  ## Build
283
286
 
@@ -331,6 +334,8 @@ code-kit merge <branch> --delegated|--person [--into branch] # merge a branch t
331
334
  code-kit requests [--json] # approval requests the hooks refused, still open, and the approvals in force
332
335
  code-kit stops [--session id] [--json] # finish checks that refused an agent's last stop and still fail
333
336
  code-kit sent-back <branch> [--reason "…"] # record a review's send-back: status shows the story as sent back until the branch moves on
337
+ code-kit settings [--json] # the harness settings in force, with their defaults
338
+ code-kit settings set <key> <value> --reason "…" # change one, as the person: written to the config and logged as a kit approval
334
339
  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
335
340
  ```
336
341
 
@@ -361,6 +366,7 @@ code-kit approve <name>... --reason "…" [--lane name] [--delegated | --via pan
361
366
  | `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
367
  | `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
368
  | `branches.protected` | no | Default `["main", "master"]` |
369
+ | `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 30; 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), and `background.pauseAtPercent` (default 80). Change them with `/harness` or `code-kit settings set`. |
364
370
 
365
371
  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.
366
372
 
package/bin/code-kit.mjs CHANGED
@@ -20,6 +20,8 @@
20
20
  // requests [--json] open approval requests and approvals in force
21
21
  // stops [--session id] [--json] finish checks that refused an agent's last stop, still failing
22
22
  // sent-back <branch> [--reason …] records that a review sent the branch back at its current commit
23
+ // settings [--json] | settings set <key> <value> --reason … [--via pane]
24
+ // the harness settings; set is the person's own change
23
25
  // record approvals a person gave in chat (with approvals.lead on, the lead runs it)
24
26
  // --config <file> reads a draft (.claude/code-kit.draft.json) instead of .claude/code-kit.json.
25
27
  import { execFileSync } from 'node:child_process';
@@ -36,6 +38,7 @@ import { nextStep } from '../hooks/lib/next.mjs';
36
38
  import { traceFile } from '../hooks/lib/trace.mjs';
37
39
  import { buildStatus } from '../hooks/lib/plan.mjs';
38
40
  import { recordSentBack } from '../hooks/lib/reviews.mjs';
41
+ import { SETTINGS, parseSetting, settingOf, withSetting } from '../hooks/lib/harness.mjs';
39
42
  import {
40
43
  APPROVAL_MINUTES,
41
44
  approvalLifetime,
@@ -119,6 +122,7 @@ function checkJson() {
119
122
  );
120
123
  report.adapters = config.adapters;
121
124
  report.docs = config.docs ?? null;
125
+ report.harness = config.harness;
122
126
  out(JSON.stringify(report, null, 2));
123
127
  }
124
128
 
@@ -446,6 +450,45 @@ function requests() {
446
450
  );
447
451
  }
448
452
 
453
+ /**
454
+ * The harness settings (`harness` in the config): listed, or one changed as the person's own act. The
455
+ * change is written to the config and given a kit approval with the person's reason, so the commit that
456
+ * keeps it logs their reason. The hooks refuse `settings set` from every agent.
457
+ */
458
+ function settings() {
459
+ const { raw, config } = load();
460
+ if (rest[0] === 'set') {
461
+ const [, key, value] = rest;
462
+ if (!key || value === undefined)
463
+ die('code-kit settings set <key> <value> --reason "<why>" [--via pane]');
464
+ if (!(key in SETTINGS))
465
+ die(`${key} isn't a harness setting. They are: ${Object.keys(SETTINGS).join(', ')}.`);
466
+ if (!reason?.trim()) die('Give a reason with --reason "…": the approval log keeps it.');
467
+ const changed = withSetting(raw, key, parseSetting(key, value));
468
+ const problems = validate(changed);
469
+ if (problems.length) die(`Nothing changed: ${problems.join('; ')}`);
470
+ writeFileSync(resolve(file), `${JSON.stringify(changed, null, 2)}\n`);
471
+ const mark =
472
+ via === 'pane' ? '(changed in the code-kit pane)' : '(changed with code-kit settings)';
473
+ grantApprovals(resolve('.'), ['kit'], null, reason, { kit: mark });
474
+ return out(
475
+ `Set harness.${key} to ${JSON.stringify(settingOf(changed.harness, key))}. Commit ${file} to keep it; the approval log will carry your reason.`,
476
+ );
477
+ }
478
+ if (rest.length) die('code-kit settings [--json] | settings set <key> <value> --reason "<why>"');
479
+ const rows = Object.entries(SETTINGS).map(([key, s]) => ({
480
+ key,
481
+ value: settingOf(config.harness, key),
482
+ default: s.default,
483
+ about: s.about,
484
+ }));
485
+ if (flag('--json')) return out(JSON.stringify(rows, null, 2));
486
+ for (const r of rows)
487
+ out(
488
+ `${r.key.padEnd(26)} ${JSON.stringify(r.value).padEnd(14)} ${r.value === r.default ? '' : `(default ${JSON.stringify(r.default)}) `}${r.about}`,
489
+ );
490
+ }
491
+
449
492
  /** Records the review's send-back of `branch` at its current commit (lib/reviews.mjs). */
450
493
  function sentBackCommand(branch) {
451
494
  load();
@@ -725,6 +768,7 @@ const commands = {
725
768
  merge: () => (rest.length === 1 ? merge(rest[0]) : usage()),
726
769
  requests,
727
770
  stops,
771
+ settings,
728
772
  'sent-back': () => (rest.length === 1 ? sentBackCommand(rest[0]) : usage()),
729
773
  trace: () => (rest.length ? trace(rest) : usage()),
730
774
  adapters,
@@ -744,7 +788,8 @@ function usage() {
744
788
  ' | next [--base ref] [--json] | adapters | trace <path>... [--json]\n' +
745
789
  ' | approve <name>... --reason "…" [--lane name] [--config file]\n' +
746
790
  ' | merge <branch> --delegated|--person [--into branch] | requests [--json]\n' +
747
- ' | stops [--session id] [--json] | sent-back <branch> [--reason "…"]',
791
+ ' | stops [--session id] [--json] | sent-back <branch> [--reason "…"]\n' +
792
+ ' | settings [--json] | settings set <key> <value> --reason "…" [--via pane]',
748
793
  );
749
794
  }
750
795
  await (commands[command] ?? usage)();
@@ -140,6 +140,11 @@ if ((delegatedMerge && /\s--person\b/.test(cmd)) || (approves && /\s--via\b/.tes
140
140
  block(
141
141
  `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}`,
142
142
  );
143
+ // The harness settings are the person's to change, from the pane or their terminal.
144
+ if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+settings\s+set\b`)))
145
+ block(
146
+ `Blocked: changing the harness settings is the person's own act, from the code-kit pane (/harness) or their terminal. Tell them what you'd change and why, and stop.\nCommand: ${cmd}`,
147
+ );
143
148
  if (delegatedMerge && actor.kind !== 'lead')
144
149
  block(
145
150
  `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}`,
@@ -1,6 +1,7 @@
1
1
  // The project's rules live in .claude/code-kit.json. The hooks read it from the project the session
2
2
  // was opened in; a project without one is not governed by the kit, and an invalid one fails closed.
3
3
  import { existsSync, readFileSync } from 'node:fs';
4
+ import { harnessProblems, harnessSettings } from './harness.mjs';
4
5
  import { join } from 'node:path';
5
6
  import { ADAPTERS, applyAdapters } from './adapters/index.mjs';
6
7
 
@@ -55,6 +56,7 @@ export function withDefaults(c) {
55
56
  checks: c.checks ?? [],
56
57
  branches: { protected: c.branches?.protected ?? ['main', 'master'] },
57
58
  approvals: { lead: c.approvals?.lead === true, delegate: delegateRules(c.approvals?.delegate) },
59
+ harness: harnessSettings(c.harness),
58
60
  };
59
61
  }
60
62
 
@@ -191,6 +193,7 @@ export function validate(c) {
191
193
  c.branches === undefined || isList(c.branches?.protected),
192
194
  '"branches.protected" must be a list of branch names',
193
195
  );
196
+ p.push(...harnessProblems(c.harness));
194
197
  return p;
195
198
  }
196
199
 
@@ -0,0 +1,131 @@
1
+ // The harness's settings (docs/specs/12-settings.md): the `harness` section of .claude/code-kit.json,
2
+ // what the mod's loop, hold, reviewer and usage read. Defaults fill what the section leaves out; a value
3
+ // out of range is a config problem like any other.
4
+
5
+ export const AUTONOMY = ['autonomous', 'propose', 'off'];
6
+ export const MODELS = ['haiku', 'sonnet', 'opus'];
7
+
8
+ /** Each setting by its dotted key: its default, and why a value is wrong (or null when it's right). */
9
+ export const SETTINGS = {
10
+ autonomy: {
11
+ default: 'autonomous',
12
+ problem: (v) => (AUTONOMY.includes(v) ? null : `must be one of ${AUTONOMY.join(', ')}`),
13
+ about: 'How the lead loop acts: on its own, by proposing each step, or not at all',
14
+ },
15
+ 'hold.minutes': {
16
+ default: 2,
17
+ problem: (v) =>
18
+ Number.isFinite(v) && v >= 0 && v <= 30
19
+ ? null
20
+ : 'must be a number of minutes from 0 (off) to 30',
21
+ about: 'How long a call waiting on your approval is held before it is refused',
22
+ },
23
+ 'stall.nudgeMinutes': {
24
+ default: 5,
25
+ problem: (v) =>
26
+ Number.isFinite(v) && v >= 1 ? null : 'must be a number of minutes, at least 1',
27
+ about: 'Minutes without a tool call before a lane agent is nudged',
28
+ },
29
+ 'stall.restartMinutes': {
30
+ default: 10,
31
+ problem: (v) =>
32
+ Number.isFinite(v) && v >= 1 ? null : 'must be a number of minutes, at least 1',
33
+ about: 'Minutes without a tool call before a lane agent is restarted',
34
+ },
35
+ 'stall.maxRestarts': {
36
+ default: 2,
37
+ problem: (v) =>
38
+ Number.isInteger(v) && v >= 0 && v <= 10 ? null : 'must be a whole number from 0 to 10',
39
+ about: 'Restarts on one story before the lane is flagged for you',
40
+ },
41
+ 'agents.reviewer.on': {
42
+ default: false,
43
+ problem: (v) => (typeof v === 'boolean' ? null : 'must be true or false'),
44
+ about: 'A background reviewer for each finished branch',
45
+ },
46
+ 'agents.reviewer.model': {
47
+ default: null,
48
+ problem: (v) =>
49
+ v === null || (typeof v === 'string' && v.length > 0)
50
+ ? null
51
+ : `must be a model (${MODELS.join(', ')}, or an id), or null for the session's`,
52
+ about: "The reviewer's model; none means the session's",
53
+ },
54
+ 'background.pauseAtPercent': {
55
+ default: 80,
56
+ problem: (v) =>
57
+ Number.isFinite(v) && v > 0 && v <= 100 ? null : 'must be a percentage from 1 to 100',
58
+ about: "The plan's 5-hour use at which background agents pause",
59
+ },
60
+ };
61
+
62
+ const get = (obj, key) =>
63
+ key.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), obj);
64
+ const set = (obj, key, value) => {
65
+ const parts = key.split('.');
66
+ let o = obj;
67
+ for (const k of parts.slice(0, -1)) o = o[k] && typeof o[k] === 'object' ? o[k] : (o[k] = {});
68
+ o[parts.at(-1)] = value;
69
+ return obj;
70
+ };
71
+
72
+ /** Every key the section holds, as dotted keys: `stall.nudgeMinutes`, `agents.reviewer.on`… */
73
+ function keysOf(obj, prefix = '') {
74
+ return Object.entries(obj ?? {}).flatMap(([k, v]) =>
75
+ v && typeof v === 'object' && !Array.isArray(v) && !(`${prefix}${k}` in SETTINGS)
76
+ ? keysOf(v, `${prefix}${k}.`)
77
+ : [`${prefix}${k}`],
78
+ );
79
+ }
80
+
81
+ /** The settings in force: the section's values over the defaults, as a nested object. A value that's
82
+ * wrong (reported by `harnessProblems`) gives way to the default. */
83
+ export function harnessSettings(section) {
84
+ const out = {};
85
+ for (const [key, s] of Object.entries(SETTINGS)) {
86
+ const v = section && typeof section === 'object' ? get(section, key) : undefined;
87
+ set(out, key, v === undefined || s.problem(v) ? s.default : v);
88
+ }
89
+ return out;
90
+ }
91
+
92
+ /** The section's problems, in the config's words. */
93
+ export function harnessProblems(section) {
94
+ if (section === undefined) return [];
95
+ if (!section || typeof section !== 'object' || Array.isArray(section))
96
+ return ['"harness" must be an object'];
97
+ const p = [];
98
+ for (const key of keysOf(section)) {
99
+ if (!(key in SETTINGS)) {
100
+ p.push(
101
+ `"harness.${key}" isn't a harness setting (they are ${Object.keys(SETTINGS).join(', ')})`,
102
+ );
103
+ continue;
104
+ }
105
+ const why = SETTINGS[key].problem(get(section, key));
106
+ if (why) p.push(`"harness.${key}" ${why}`);
107
+ }
108
+ const h = harnessSettings(section);
109
+ if (h.stall.restartMinutes <= h.stall.nudgeMinutes)
110
+ p.push('"harness.stall.restartMinutes" must be more than "harness.stall.nudgeMinutes"');
111
+ return p;
112
+ }
113
+
114
+ /** A value typed on the command line, as the setting's type: numbers, true and false, null, or text. */
115
+ export function parseSetting(key, text) {
116
+ const d = SETTINGS[key]?.default;
117
+ if (text === 'null') return null;
118
+ if (typeof d === 'boolean' || text === 'true' || text === 'false')
119
+ return text === 'true' ? true : text === 'false' ? false : text;
120
+ if (typeof d === 'number' && /^-?\d+(\.\d+)?$/.test(text)) return Number(text);
121
+ return text;
122
+ }
123
+
124
+ /** The raw config with one harness setting changed (a copy). */
125
+ export function withSetting(raw, key, value) {
126
+ const copy = structuredClone(raw);
127
+ copy.harness = set(copy.harness ?? {}, key, value);
128
+ return copy;
129
+ }
130
+
131
+ export { get as settingOf };
@@ -0,0 +1,25 @@
1
+ // Hold and ask (spec 06), the pure parts. The hooks stay the only judge: the mod lets a call run,
2
+ // and when the result is a refusal a person's approval would allow, it holds the call, asks, writes the
3
+ // approval and retries the same call (docs/spikes/harness.md, spike B). This reads such a refusal.
4
+ import { refusalCard } from './view.mjs';
5
+
6
+ /** The kinds of refusal a person's approval would allow (HOLD-1): a new package, a protected file, the kit. */
7
+ const HOLDABLE = new Set(['dependency', 'approval', 'kit']);
8
+
9
+ /**
10
+ * From a refused call's result text, what would let it through: { kind, names, lane, what, title }, or
11
+ * null when no approval a person can give would allow it (another lane's files, force-push, the log…).
12
+ */
13
+ export function holdable(resultText) {
14
+ const card = refusalCard(resultText);
15
+ if (!card || !HOLDABLE.has(card.kind) || !card.request?.names.length) return null;
16
+ return { kind: card.kind, title: card.title, ...card.request };
17
+ }
18
+
19
+ /** A tool call's result as text, whichever shape the engine gave it. */
20
+ export function resultText(result) {
21
+ if (!result) return '';
22
+ if (typeof result.deny === 'string') return result.deny;
23
+ if (typeof result.text === 'string') return result.text;
24
+ return typeof result.result === 'string' ? result.result : '';
25
+ }
@@ -12,18 +12,25 @@ import {
12
12
  NOT_CODE_KIT,
13
13
  PANE_ID,
14
14
  RESULT_ID,
15
+ SETTINGS_ID,
15
16
  approvalsText,
16
17
  agentsAtWork,
18
+ attribution,
17
19
  approvePane,
18
20
  band,
19
21
  bandLines,
22
+ finishedStories,
20
23
  lanesPane,
21
24
  parseJson,
25
+ planOf,
22
26
  prefilledReason,
23
27
  projectState,
24
28
  refusalCard,
25
29
  refusalView,
26
30
  resultPane,
31
+ settingsView,
32
+ usageSummary,
33
+ withUsage,
27
34
  } from './view.mjs';
28
35
 
29
36
  // What the session knows of the project, read again when it changes.
@@ -34,6 +41,11 @@ let approving = null; // the open Approve… confirmation: { request, reason, er
34
41
  let result = null; // what the last merge reported
35
42
  let notice = null; // why the person's last act failed
36
43
  let act = null; // the session's actions, made at session start
44
+ let settingRows = []; // the harness settings, from `code-kit settings --json`
45
+ let pendingSetting = null; // a change waiting for the person's reason: { key, value, reason, error }
46
+ let ledger = {}; // the session's tokens by agent and story (USE-1)
47
+ let rateLimits = []; // the session's limits, as session.measure last gave them
48
+ const agentsSeen = new Map(); // agent id → its $.agent.list() entry, kept once it leaves the list
37
49
  const refusals = new Map(); // tool_use_id → the refusal text a hook gave (CARD-1)
38
50
  const expanded = new Set(); // cards showing their raw text
39
51
  const REFUSALS_KEPT = 200;
@@ -63,6 +75,14 @@ export function register(on) {
63
75
  description: 'Run code-kit verify on this branch, checks included',
64
76
  })
65
77
  .catch(unavailable('verify-branch'));
78
+ await $.command
79
+ .register({
80
+ name: 'harness',
81
+ description: "code-kit's harness settings: autonomy, agents, hold time",
82
+ immediate: true,
83
+ })
84
+ .catch(unavailable('harness'));
85
+ rateLimits = (await $.session.usage().catch(() => null))?.rateLimits ?? [];
66
86
  const cli = `${$.plugin.root}/bin/code-kit.mjs`;
67
87
  const cwd = e.cwd ?? (await $.session.cwd());
68
88
  const session = await $.session.id();
@@ -231,6 +251,74 @@ export function register(on) {
231
251
  const ran = await $.process.run(['node', cli, 'verify'], { cwd, timeoutMs: 600000 });
232
252
  return { text: `${ran.stdout}${ran.stderr}`.trim() };
233
253
  },
254
+ // SET-2: the Settings view; a change is the person's, confirmed with a reason.
255
+ harness: async () => {
256
+ if ((await $.ui.panes()).some((p) => p.id === SETTINGS_ID)) {
257
+ await $.ui.close({ id: SETTINGS_ID });
258
+ return {};
259
+ }
260
+ await act.reload();
261
+ if (model.state.kind === 'none') return { text: NOT_CODE_KIT };
262
+ settingRows = (await json('settings')) ?? [];
263
+ await $.ui.open({ id: SETTINGS_ID, title: 'Harness', focus: true, closeOnEscape: true });
264
+ $.ui.invalidate('ui.render');
265
+ return {};
266
+ },
267
+ chooseSetting: (key, value) => {
268
+ const row = settingRows.find((x) => x.key === key);
269
+ if (row && String(row.value ?? 'null') === value) return;
270
+ pendingSetting = { key, value, reason: '', error: null };
271
+ $.ui.invalidate('ui.render');
272
+ },
273
+ confirmSetting: async (reason) => {
274
+ if (!pendingSetting) return;
275
+ if (!reason?.trim()) {
276
+ pendingSetting = {
277
+ ...pendingSetting,
278
+ error: 'Give a reason: the approval log keeps it.',
279
+ };
280
+ $.ui.invalidate('ui.render');
281
+ return;
282
+ }
283
+ const ran = await $.process.run(
284
+ [
285
+ 'node',
286
+ cli,
287
+ 'settings',
288
+ 'set',
289
+ pendingSetting.key,
290
+ pendingSetting.value,
291
+ '--reason',
292
+ reason.trim(),
293
+ '--via',
294
+ 'pane',
295
+ ],
296
+ { cwd },
297
+ );
298
+ if (ran.exitCode !== 0) {
299
+ pendingSetting = {
300
+ ...pendingSetting,
301
+ error: (ran.stderr || ran.stdout).trim().split('\n')[0],
302
+ };
303
+ $.ui.invalidate('ui.render');
304
+ return;
305
+ }
306
+ pendingSetting = null;
307
+ settingRows = (await json('settings')) ?? settingRows;
308
+ await act.reload();
309
+ },
310
+ cancelSetting: () => {
311
+ pendingSetting = null;
312
+ $.ui.invalidate('ui.render');
313
+ },
314
+ // USE-1: a model request's tokens, put down to the agent that made it and its story and lane.
315
+ measure: async (agentId, usage) => {
316
+ if (agentId && !agentsSeen.has(agentId))
317
+ for (const a of (await $.agent.list()) ?? []) agentsSeen.set(a.id, a);
318
+ const who = attribution(agentId, agentsSeen.get(agentId) ?? null, model.check, model.state);
319
+ ledger = withUsage(ledger, who, usage);
320
+ $.ui.invalidate('ui.render');
321
+ },
234
322
  dismiss: async () => {
235
323
  notice = null;
236
324
  $.ui.invalidate('ui.render');
@@ -265,6 +353,9 @@ export function register(on) {
265
353
  text: `code-kit is still starting; try /${name} again in a moment.`,
266
354
  });
267
355
  on('command.run', { command: 'lanes' }, async ($, e) => (act ? act.lanes() : starting('lanes')));
356
+ on('command.run', { command: 'harness' }, async ($, e) =>
357
+ act ? act.harness() : starting('harness'),
358
+ );
268
359
  on('command.run', { command: 'approvals' }, async ($, e) =>
269
360
  act ? act.approvals() : starting('approvals'),
270
361
  );
@@ -313,6 +404,27 @@ export function register(on) {
313
404
  });
314
405
  });
315
406
 
407
+ // USE-1: each model request's usage, as the request ends; the lead's requests carry no agentId.
408
+ on('turn.step', async function* ($, e, next) {
409
+ const res = yield* next(e);
410
+ if (res?.usage && act) await act.measure(e.agentId, res.usage).catch(() => {});
411
+ return res;
412
+ });
413
+ // USE-4: the plan's 5-hour use, as Claude Code measures it.
414
+ on('session.measure', async ($, e, next) => {
415
+ if (e.changed.includes('rateLimits')) {
416
+ rateLimits = e.rateLimits;
417
+ $.ui.invalidate('ui.render');
418
+ }
419
+ return next(e);
420
+ });
421
+
422
+ // What the pane and band show of usage: the session's tokens and the plan's use.
423
+ const usageNow = () => ({
424
+ usage: usageSummary(ledger, finishedStories(model.state)),
425
+ plan: planOf(rateLimits, model.check?.harness?.background?.pauseAtPercent),
426
+ });
427
+
316
428
  on('turn.start', async ($, e, next) => {
317
429
  if (reviewTurn === 'next') reviewTurn = e.turnId;
318
430
  return next(e);
@@ -327,14 +439,15 @@ export function register(on) {
327
439
  return next(e);
328
440
  });
329
441
 
330
- on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) =>
331
- lanesPane(model.state, $.ui.resolve(e)),
332
- );
442
+ on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
443
+ const { usage, plan } = usageNow();
444
+ return lanesPane(model.state, $.ui.resolve(e), usage, plan);
445
+ });
333
446
 
334
447
  // BAND-2: absent when nothing waits. Its lines go above whatever else the band holds (another
335
448
  // plugin's lines, such as Context Graph's, or Claude Code's own), which it keeps.
336
449
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
337
- const lines = bandLines({ ...model, reviewing, notice });
450
+ const lines = bandLines({ ...model, reviewing, notice, ...usageNow() });
338
451
  if (!lines.length || !act) return next(e);
339
452
  const ours = band(lines, $.ui.resolve(e), (id, line) => act[id](line));
340
453
  const below = await next(e);
@@ -353,6 +466,18 @@ export function register(on) {
353
466
  });
354
467
  });
355
468
 
469
+ on('ui.render', { component: 'Pane', requestId: SETTINGS_ID }, async ($, e) =>
470
+ settingsView(settingRows, pendingSetting, $.ui.resolve(e), {
471
+ onChoose: (key, value) => act.chooseSetting(key, value),
472
+ onReason: (value) => {
473
+ if (pendingSetting) pendingSetting = { ...pendingSetting, reason: value };
474
+ $.ui.invalidate('ui.render');
475
+ },
476
+ onConfirm: (reason) => act.confirmSetting(reason),
477
+ onCancel: () => act.cancelSetting(),
478
+ }),
479
+ );
480
+
356
481
  on('ui.render', { component: 'Pane', requestId: RESULT_ID }, async ($, e) =>
357
482
  resultPane(result, $.ui.resolve(e)),
358
483
  );
@@ -6,6 +6,7 @@ import { expect, mock, test } from 'claude-code/testing';
6
6
  const PANE = 'code-kit-lanes';
7
7
  const APPROVE = 'code-kit-approve';
8
8
  const RESULT = 'code-kit-result';
9
+ const SETTINGS = 'code-kit-settings';
9
10
 
10
11
  /** A project as the stubs report it; tests change it to move the world on. */
11
12
  function project() {
@@ -68,7 +69,8 @@ function project() {
68
69
  taken: [] as string[], // command names another command already holds
69
70
  stops: [] as any[],
70
71
  refs: ' aaa refs/heads/main\n bbb refs/heads/web/st-4\n',
71
- agents: [] as { type: string; status: string }[],
72
+ agents: [] as { type: string; status: string; id?: string; description?: string }[],
73
+ limits: [] as { kind: string; percentUsed: number }[],
72
74
  approveExit: 0,
73
75
  mergeExit: 0,
74
76
  answer: 'Merge',
@@ -78,6 +80,18 @@ function project() {
78
80
  prompts: [] as string[],
79
81
  acts: [] as string[][],
80
82
  runs: 0,
83
+ settings: [
84
+ {
85
+ key: 'autonomy',
86
+ value: 'autonomous',
87
+ default: 'autonomous',
88
+ about: 'How the lead loop acts',
89
+ },
90
+ { key: 'hold.minutes', value: 2, default: 2, about: 'How long a call is held' },
91
+ { key: 'agents.reviewer.on', value: false, default: false, about: 'A background reviewer' },
92
+ { key: 'agents.reviewer.model', value: null, default: null, about: "The reviewer's model" },
93
+ ] as any[],
94
+ setExit: 0,
81
95
  };
82
96
  }
83
97
  type World = ReturnType<typeof project>;
@@ -119,6 +133,29 @@ function stub(on: any, w: World) {
119
133
  ? ran(1, '', 'Nothing was merged. code-kit verify found 1 problem(s) on web/st-4')
120
134
  : ran(0, 'Merged web/st-4 into main.');
121
135
  }
136
+ if (sub === 'settings' && argv[3] === 'set') {
137
+ w.acts.push([...argv.slice(2)]);
138
+ if (w.setExit)
139
+ return ran(
140
+ 1,
141
+ '',
142
+ '"harness.hold.minutes" must be a number of minutes from 0 (off) to 30\nNothing was changed.',
143
+ );
144
+ const row = w.settings.find((s) => s.key === argv[4]);
145
+ const raw = argv[5];
146
+ row.value =
147
+ raw === 'null'
148
+ ? null
149
+ : raw === 'true'
150
+ ? true
151
+ : raw === 'false'
152
+ ? false
153
+ : /^\d+$/.test(raw)
154
+ ? Number(raw)
155
+ : raw;
156
+ return ran(0, `Set harness.${argv[4]} to ${raw}.`);
157
+ }
158
+ if (sub === 'settings') return ran(0, JSON.stringify(w.settings));
122
159
  return ran(1, '', `unexpected ${argv.join(' ')}`);
123
160
  });
124
161
  on('session.start', () => ({ cwd: '/work' }));
@@ -135,6 +172,19 @@ function stub(on: any, w: World) {
135
172
  w.refusal ? { deny: w.refusal } : { result: { stdout: '', stderr: '', interrupted: false } },
136
173
  );
137
174
  on('agent.list', () => ({ value: w.agents }));
175
+ on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: w.limits } }));
176
+ on('session.measure', ($: any, e: any) => ({ changed: e.changed }));
177
+ // A model request, answered with the usage the test gave it.
178
+ on('turn.step', async function* ($: any, e: any) {
179
+ return {
180
+ turnId: e.turnId,
181
+ index: e.index,
182
+ answer: '',
183
+ toolUses: [],
184
+ stopReason: 'end_turn',
185
+ usage: e.messageCount === 0 ? null : { model: e.model, ...stepUsage(e.messageCount) },
186
+ };
187
+ } as any);
138
188
  on('fs.stat', () => ({ value: { kind: 'file', size: 1, mtimeMs: 1, isLink: false } }));
139
189
  on('fs.list', () => ({ value: [] }));
140
190
  on('session.cwd', () => ({ value: '/work' }));
@@ -163,6 +213,30 @@ function stub(on: any, w: World) {
163
213
  });
164
214
  }
165
215
 
216
+ /** A request of n thousand tokens, split as a model request's usage is. */
217
+ function stepUsage(thousands: number) {
218
+ const n = thousands * 1000;
219
+ return {
220
+ input_tokens: n * 0.3,
221
+ cache_creation_input_tokens: n * 0.1,
222
+ output_tokens: n * 0.1,
223
+ cache_read_input_tokens: n * 0.5,
224
+ };
225
+ }
226
+
227
+ /** One model request by an agent (or the lead) of so many thousand tokens, run to its end. */
228
+ async function request($: any, agentId: string | undefined, thousands: number) {
229
+ const s = $.turn.step({
230
+ turnId: `t-${agentId ?? 'lead'}`,
231
+ index: 0,
232
+ model: 'claude-sonnet-5-5',
233
+ messageCount: thousands, // the stub beneath answers with this many thousand tokens
234
+ ...(agentId ? { agentId } : {}),
235
+ });
236
+ for await (const _ of s);
237
+ return s.result;
238
+ }
239
+
166
240
  /** A session started on the project, with the mock clock. */
167
241
  async function start($: any, on: any, w: World) {
168
242
  stub(on, w);
@@ -714,3 +788,228 @@ test(
714
788
  await ui.unmount();
715
789
  },
716
790
  );
791
+
792
+ // --- the harness settings -------------------------------------------------------------------------
793
+
794
+ const harness = ($: any) => $.command.run({ command: 'harness', args: '' });
795
+
796
+ test('SET-2 /harness opens the settings, each with its value, and /harness again closes it', async ($, on) => {
797
+ const w = project();
798
+ await start($, on, w);
799
+ await harness($);
800
+ expect(w.opened).toContain(SETTINGS);
801
+ const ui = await pane($, SETTINGS);
802
+ expect(await ui.find({ type: 'Text', text: 'hold.minutes' })).toBeDefined();
803
+ expect((await ui.find({ key: 'set-hold.minutes' }))?.props.value).toBe('2');
804
+ expect((await ui.find({ key: 'set-autonomy' }))?.props.value).toBe('autonomous');
805
+ await ui.unmount();
806
+ await harness($);
807
+ expect(w.closed).toContain(SETTINGS);
808
+ });
809
+
810
+ test('SET-2 a change asks for a reason, then is written by the CLI as the pane', async ($, on) => {
811
+ const w = project();
812
+ await start($, on, w);
813
+ await harness($);
814
+ const ui = await pane($, SETTINGS);
815
+ await $.ui.select({
816
+ plugin: 'code-kit',
817
+ key: 'set-autonomy',
818
+ value: 'propose',
819
+ requestId: SETTINGS,
820
+ });
821
+ expect(w.acts).toEqual([]);
822
+ expect(await ui.find({ type: 'Text', text: /Set autonomy to propose\?/ })).toBeDefined();
823
+ await $.ui.input({ plugin: 'code-kit', key: 'settings-reason', text: ' ' });
824
+ expect(w.acts).toEqual([]);
825
+ expect(await ui.find({ type: 'Text', text: /Give a reason/ })).toBeDefined();
826
+ await $.ui.input({ plugin: 'code-kit', key: 'settings-reason', text: 'watch it for a week' });
827
+ expect(w.acts).toEqual([
828
+ ['settings', 'set', 'autonomy', 'propose', '--reason', 'watch it for a week', '--via', 'pane'],
829
+ ]);
830
+ expect(await ui.find({ key: 'settings-confirm' })).toBeUndefined();
831
+ expect((await ui.find({ key: 'set-autonomy' }))?.props.value).toBe('propose');
832
+ await ui.unmount();
833
+ });
834
+
835
+ test('SET-2 a number is typed; one the CLI refuses shows why and changes nothing', async ($, on) => {
836
+ const w = project();
837
+ w.setExit = 1;
838
+ await start($, on, w);
839
+ await harness($);
840
+ const ui = await pane($, SETTINGS);
841
+ await $.ui.input({ plugin: 'code-kit', key: 'set-hold.minutes', text: '45' });
842
+ await $.ui.input({ plugin: 'code-kit', key: 'settings-reason', text: 'longer' });
843
+ expect(await ui.find({ type: 'Text', text: /from 0 \(off\) to 30/ })).toBeDefined();
844
+ expect(w.settings[1].value).toBe(2);
845
+ await press($, 'settings-cancel', SETTINGS);
846
+ expect(await ui.find({ key: 'settings-confirm' })).toBeUndefined();
847
+ await ui.unmount();
848
+ });
849
+
850
+ test("SET-2 the reviewer's model can go back to the session's", async ($, on) => {
851
+ const w = project();
852
+ w.settings[3].value = 'haiku';
853
+ await start($, on, w);
854
+ await harness($);
855
+ const ui = await pane($, SETTINGS);
856
+ expect(await ui.find({ type: 'Text', text: /default the session's/ })).toBeDefined();
857
+ await $.ui.select({
858
+ plugin: 'code-kit',
859
+ key: 'set-agents.reviewer.model',
860
+ value: '',
861
+ requestId: SETTINGS,
862
+ });
863
+ await press($, 'settings-ok', SETTINGS);
864
+ expect(w.acts).toEqual([]);
865
+ await $.ui.input({ plugin: 'code-kit', key: 'settings-reason', text: 'same as the lead' });
866
+ expect(w.acts[0].slice(0, 4)).toEqual(['settings', 'set', 'agents.reviewer.model', 'null']);
867
+ await ui.unmount();
868
+ });
869
+
870
+ test('CARD-4 outside a code-kit project, /harness says so and opens nothing', async ($, on) => {
871
+ const w = project();
872
+ w.check.exists = false;
873
+ w.check.valid = false;
874
+ await start($, on, w);
875
+ const said = await harness($);
876
+ expect(String(said?.text)).toMatch(/code-kit/);
877
+ expect(w.opened).not.toContain(SETTINGS);
878
+ });
879
+
880
+ // --- usage per lane ---------------------------------------------------------------------------------
881
+
882
+ const working = (id: string, type: string, description: string) => ({
883
+ id,
884
+ type,
885
+ description,
886
+ status: 'running',
887
+ });
888
+
889
+ test("USE-1 a lane agent's requests show on its lane's row and its story", async ($, on) => {
890
+ const w = project();
891
+ w.agents = [working('a1', 'web-engineer', 'Build ST-4: the booking form')];
892
+ const clock = await start($, on, w);
893
+ await lanes($);
894
+ await request($, 'a1', 120);
895
+ await request($, 'a1', 80);
896
+ await clock.advance(2000);
897
+ const ui = await pane($, PANE);
898
+ expect((await ui.find({ key: 'lane-web' }))?.text).toContain('200k');
899
+ expect((await ui.find({ key: 'usage-ST-4' }))?.text).toContain('200k');
900
+ expect((await ui.find({ key: 'lane-api' }))?.text).not.toMatch(/\d+k/);
901
+ await ui.unmount();
902
+ });
903
+
904
+ test("USE-1 the lead's requests are the lead's; an agent's brief names its story", async ($, on) => {
905
+ const w = project();
906
+ w.agents = [working('a2', 'core-engineer', 'ST-9 pricing rules')];
907
+ await start($, on, w);
908
+ await lanes($);
909
+ await request($, undefined, 30);
910
+ await request($, 'a2', 50);
911
+ const ui = await pane($, PANE);
912
+ expect((await ui.find({ key: 'lane-lead' }))?.text).toContain('30k');
913
+ expect((await ui.find({ key: 'lane-core' }))?.text).toContain('50k');
914
+ expect((await ui.find({ key: 'usage-ST-9' }))?.text).toContain('50k');
915
+ await ui.unmount();
916
+ });
917
+
918
+ test("USE-2 the background agents' share and the plan's 5-hour use", async ($, on) => {
919
+ const w = project();
920
+ w.limits = [{ kind: 'five_hour', percentUsed: 62 }];
921
+ w.agents = [
922
+ working('a1', 'web-engineer', 'ST-4 booking form'),
923
+ working('r1', 'code-reviewer', 'Review web/st-4'),
924
+ ];
925
+ await start($, on, w);
926
+ await lanes($);
927
+ await request($, 'a1', 150);
928
+ await request($, 'r1', 50);
929
+ const ui = await pane($, PANE);
930
+ expect(await ui.find({ type: 'Text', text: 'Background agents 50k (25%)' })).toBeDefined();
931
+ expect(await ui.find({ type: 'Text', text: '62% of the 5-hour window' })).toBeDefined();
932
+ expect(await ui.find({ type: 'Text', text: 'This session 200k tokens' })).toBeDefined();
933
+ await ui.unmount();
934
+ });
935
+
936
+ test('USE-3 a story past three times the median of three finished ones is flagged in the band', async ($, on) => {
937
+ const w = project();
938
+ w.stories = [
939
+ ...['ST-1', 'ST-2', 'ST-3'].map((id) => ({
940
+ id,
941
+ title: id,
942
+ lane: 'api',
943
+ state: 'done',
944
+ branch: `api/${id.toLowerCase()}`,
945
+ waitingOn: [],
946
+ })),
947
+ ...w.stories,
948
+ ];
949
+ w.agents = ['ST-1', 'ST-2', 'ST-3', 'ST-4'].map((id, i) =>
950
+ working(`a${i}`, i < 3 ? 'api-engineer' : 'web-engineer', `${id} work`),
951
+ );
952
+ await start($, on, w);
953
+ await request($, 'a0', 190);
954
+ await request($, 'a1', 200);
955
+ await request($, 'a2', 210);
956
+ await request($, 'a3', 500);
957
+ let ui = await bandUi($);
958
+ expect(await ui.find({ type: 'Text', text: /has used/ })).toBeUndefined();
959
+ await ui.unmount();
960
+ await request($, 'a3', 200);
961
+ ui = await bandUi($);
962
+ expect(await ui.find({ type: 'Text', text: 'ST-4 has used 3.5× the usual' })).toBeDefined();
963
+ await ui.unmount();
964
+ });
965
+
966
+ test('USE-3 with fewer than three finished, nothing is flagged', async ($, on) => {
967
+ const w = project();
968
+ w.stories = [
969
+ { id: 'ST-1', title: 'a', lane: 'api', state: 'done', branch: 'api/st-1', waitingOn: [] },
970
+ ...w.stories,
971
+ ];
972
+ w.agents = [working('a0', 'api-engineer', 'ST-1'), working('a1', 'web-engineer', 'ST-4')];
973
+ await start($, on, w);
974
+ await request($, 'a0', 10);
975
+ await request($, 'a1', 900);
976
+ const ui = await bandUi($);
977
+ expect(await ui.find({ type: 'Text', text: /has used/ })).toBeUndefined();
978
+ await ui.unmount();
979
+ });
980
+
981
+ test('USE-4 past the pause point the band says background agents are paused, and below it not', async ($, on) => {
982
+ const w = project();
983
+ await start($, on, w);
984
+ const measure = (percentUsed: number) =>
985
+ $.session.measure({
986
+ context: {} as any,
987
+ rateLimits: [{ kind: 'five_hour', percentUsed }],
988
+ changed: ['rateLimits'],
989
+ });
990
+ await measure(82);
991
+ let ui = await bandUi($);
992
+ expect(
993
+ await ui.find({ type: 'Text', text: 'background agents paused: plan at 82%' }),
994
+ ).toBeDefined();
995
+ await ui.unmount();
996
+ await measure(79);
997
+ ui = await bandUi($);
998
+ expect(await ui.find({ type: 'Text', text: /paused/ })).toBeUndefined();
999
+ await ui.unmount();
1000
+ });
1001
+
1002
+ test('USE-4 the pause point is the harness setting', async ($, on) => {
1003
+ const w = project();
1004
+ (w.check as any).harness = { background: { pauseAtPercent: 90 } };
1005
+ w.limits = [{ kind: 'five_hour', percentUsed: 85 }];
1006
+ await start($, on, w);
1007
+ let ui = await bandUi($);
1008
+ expect(await ui.find({ type: 'Text', text: /paused/ })).toBeUndefined();
1009
+ await ui.unmount();
1010
+ w.limits = [{ kind: 'five_hour', percentUsed: 91 }];
1011
+ await $.session.measure({ context: {} as any, rateLimits: w.limits, changed: ['rateLimits'] });
1012
+ ui = await bandUi($);
1013
+ expect(await ui.find({ type: 'Text', text: /plan at 91%/ })).toBeDefined();
1014
+ await ui.unmount();
1015
+ });
@@ -61,7 +61,8 @@ function laneRow(name, agent, stories, atWork) {
61
61
  * subagent types at work:
62
62
  * { kind: 'none' } no .claude/code-kit.json
63
63
  * { kind: 'invalid', problems } a config that doesn't validate
64
- * { kind: 'ok', lanes, ready, planGaps } a row per lane, what's ready, the plan's gaps (or null)
64
+ * { kind: 'ok', lanes, ready, planGaps, stories } a row per lane, what's ready, the plan's gaps (or
65
+ * null), and each story's id, lane and state
65
66
  */
66
67
  export function projectState(check, status, next = null, atWork = new Set()) {
67
68
  if (!check || check.exists === false) return { kind: 'none' };
@@ -77,7 +78,13 @@ export function projectState(check, status, next = null, atWork = new Set()) {
77
78
  const story = stories.find((s) => s.id === id);
78
79
  return { id, title: story?.title ?? '', lane: story?.lane ?? null };
79
80
  });
80
- return { kind: 'ok', lanes, ready, planGaps: status ? (status.problems ?? []).length : null };
81
+ return {
82
+ kind: 'ok',
83
+ lanes,
84
+ ready,
85
+ planGaps: status ? (status.problems ?? []).length : null,
86
+ stories: stories.map((s) => ({ id: s.id, lane: s.lane, state: s.state })),
87
+ };
81
88
  }
82
89
 
83
90
  const STATE_COLOR = {
@@ -88,8 +95,11 @@ const STATE_COLOR = {
88
95
  idle: undefined,
89
96
  };
90
97
 
91
- /** The Lanes pane's body, drawn with the elements `$.ui.resolve(e)` returned. */
92
- export function lanesPane(state, { Box, Text }) {
98
+ /**
99
+ * The Lanes pane's body, drawn with the elements `$.ui.resolve(e)` returned; `usage` is
100
+ * usageSummary's, `plan` planOf's (USE-2), either null before there is any.
101
+ */
102
+ export function lanesPane(state, { Box, Text }, usage = null, plan = null) {
93
103
  const text = (value, style = {}) => Text({ ...style, children: [value] });
94
104
  if (!state || state.kind === 'none') return text(NOT_CODE_KIT, { dimColor: true });
95
105
  if (state.kind === 'invalid')
@@ -108,6 +118,9 @@ export function lanesPane(state, { Box, Text }) {
108
118
  text(l.name.padEnd(width), { bold: true }),
109
119
  text(l.state.padEnd(9), STATE_COLOR[l.state] ? { color: STATE_COLOR[l.state] } : {}),
110
120
  text(l.agent ?? 'the main session', { dimColor: true }),
121
+ ...(usage?.lanes[l.name]
122
+ ? [text(tokens(usage.lanes[l.name]), { dimColor: true })]
123
+ : []),
111
124
  ],
112
125
  }),
113
126
  ...(l.story
@@ -152,10 +165,59 @@ export function lanesPane(state, { Box, Text }) {
152
165
  ...notes,
153
166
  Box({ flexDirection: 'column', children: rows }),
154
167
  Box({ flexDirection: 'column', children: ready }),
168
+ ...usageSection(usage, plan, text, Box),
155
169
  ],
156
170
  });
157
171
  }
158
172
 
173
+ /** USE-2: the plan's 5-hour use as a bar, each story's tokens, and the background agents' share. */
174
+ function usageSection(usage, plan, text, Box) {
175
+ const lines = [];
176
+ if (plan?.percent != null)
177
+ lines.push(
178
+ Box({
179
+ key: 'usage-plan',
180
+ flexDirection: 'row',
181
+ columnGap: 2,
182
+ children: [
183
+ text('Plan'),
184
+ text(planBar(plan.percent), { color: plan.paused ? 'red' : 'green' }),
185
+ text(`${plan.percent}% of the 5-hour window`, { dimColor: true }),
186
+ ...(plan.paused ? [text('background agents paused', { color: 'red' })] : []),
187
+ ],
188
+ }),
189
+ );
190
+ if (usage?.total) {
191
+ const flagged = new Map(usage.outliers.map((o) => [o.id, o.ratio]));
192
+ for (const [id, n] of Object.entries(usage.stories).sort((a, b) => b[1] - a[1]))
193
+ lines.push(
194
+ Box({
195
+ key: `usage-${id}`,
196
+ flexDirection: 'row',
197
+ columnGap: 2,
198
+ children: [
199
+ text(id),
200
+ text(tokens(n), { dimColor: true }),
201
+ ...(flagged.has(id)
202
+ ? [text(`${flagged.get(id)}× the usual`, { color: 'yellow' })]
203
+ : []),
204
+ ],
205
+ }),
206
+ );
207
+ if (usage.background)
208
+ lines.push(
209
+ text(
210
+ `Background agents ${tokens(usage.background)} (${Math.round((usage.background / usage.total) * 100)}%)`,
211
+ { dimColor: true },
212
+ ),
213
+ );
214
+ lines.push(text(`This session ${tokens(usage.total)} tokens`, { dimColor: true }));
215
+ }
216
+ return lines.length
217
+ ? [Box({ flexDirection: 'column', children: [text('Usage', { bold: true }), ...lines] })]
218
+ : [];
219
+ }
220
+
159
221
  // --- the band, and the person's acts -----------------------------------------------------------
160
222
 
161
223
  export const APPROVE_ID = 'code-kit-approve';
@@ -185,9 +247,18 @@ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
185
247
  /**
186
248
  * The band's lines (BAND-2): one per kind of thing waiting, each with its actions, and none when
187
249
  * nothing waits. `reviewing` holds the stories the person has asked the lead to review (ACT-3);
188
- * `notice` is why the person's last act failed, until they dismiss it.
250
+ * `notice` is why the person's last act failed, until they dismiss it. `usage` (usageSummary's) flags
251
+ * outliers (USE-3), and `plan` (planOf's) says when background agents are paused (USE-4).
189
252
  */
190
- export function bandLines({ state, requests, stops, reviewing = new Set(), notice = null }) {
253
+ export function bandLines({
254
+ state,
255
+ requests,
256
+ stops,
257
+ reviewing = new Set(),
258
+ notice = null,
259
+ usage = null,
260
+ plan = null,
261
+ }) {
191
262
  const lines = [];
192
263
  if (notice)
193
264
  lines.push({ kind: 'notice', text: notice, actions: [{ id: 'dismiss', label: 'Dismiss' }] });
@@ -224,6 +295,21 @@ export function bandLines({ state, requests, stops, reviewing = new Set(), notic
224
295
  text: `Finish check failing: ${stops[0].title}${stops.length > 1 ? ` (and ${stops.length - 1} more)` : ''}`,
225
296
  actions: [{ id: 'lanes', label: 'Lanes' }],
226
297
  });
298
+ if (usage?.outliers.length) {
299
+ const [top] = usage.outliers;
300
+ const more = usage.outliers.length - 1;
301
+ lines.push({
302
+ kind: 'usage',
303
+ text: `${top.id} has used ${top.ratio}× the usual${more ? ` (and ${more} more)` : ''}`,
304
+ actions: [{ id: 'lanes', label: 'Lanes' }],
305
+ });
306
+ }
307
+ if (plan?.paused)
308
+ lines.push({
309
+ kind: 'paused',
310
+ text: `background agents paused: plan at ${plan.percent}%`,
311
+ actions: [],
312
+ });
227
313
  // BAND-4: a digit for every action, in the order they're drawn.
228
314
  let digit = 0;
229
315
  for (const line of lines)
@@ -233,7 +319,14 @@ export function bandLines({ state, requests, stops, reviewing = new Set(), notic
233
319
 
234
320
  /** The band, drawn with the elements `$.ui.resolve(e)` returned; `onAction(id, line)` acts. */
235
321
  export function band(lines, { Box, Text, Button }, onAction) {
236
- const COLOR = { notice: 'red', approvals: 'yellow', review: 'blue', finish: 'red' };
322
+ const COLOR = {
323
+ notice: 'red',
324
+ approvals: 'yellow',
325
+ review: 'blue',
326
+ finish: 'red',
327
+ usage: 'yellow',
328
+ paused: 'yellow',
329
+ };
237
330
  return Box({
238
331
  flexDirection: 'column',
239
332
  children: lines.map((line) =>
@@ -351,6 +444,25 @@ export function refusalCard(raw) {
351
444
  request: request(`write ${m[1]}`),
352
445
  raw: text,
353
446
  };
447
+ m = first.match(/^(\S+) is (.+); committing it needs a person's approval in force\./);
448
+ if (m)
449
+ return {
450
+ kind: 'approval',
451
+ title: `Commit refused: ${m[1]}`,
452
+ why: `It's ${m[2]}.`,
453
+ todo: 'A person approves it, here or with the `!` command, for 60 minutes.',
454
+ request: request(`commit ${m[1]}`),
455
+ raw: text,
456
+ };
457
+ if (/^the \.claude kit is changed only by the lead or with a person's approval\./.test(first))
458
+ return {
459
+ kind: 'kit',
460
+ title: 'Kit edit refused',
461
+ why: "The .claude kit is changed only by the lead or with a person's approval.",
462
+ todo: 'A person approves it, here or with the `!` command, for 60 minutes.',
463
+ request: request('change the .claude kit'),
464
+ raw: text,
465
+ };
354
466
  m = first.match(/^a new dependency \((.+?)\) needs a person's approval\./);
355
467
  if (m)
356
468
  return {
@@ -425,3 +537,226 @@ export function approvalsText(requests) {
425
537
  ];
426
538
  return lines.join('\n');
427
539
  }
540
+
541
+ // --- the harness settings (SET-2) --------------------------------------------------------------
542
+
543
+ export const SETTINGS_ID = 'code-kit-settings';
544
+ const CHOICES = {
545
+ autonomy: ['autonomous', 'propose', 'off'],
546
+ 'agents.reviewer.model': ['', 'haiku', 'sonnet', 'opus'],
547
+ };
548
+ const shown = (v) =>
549
+ v === null || v === '' ? "the session's" : v === true ? 'on' : v === false ? 'off' : String(v);
550
+
551
+ /**
552
+ * The Settings view: each setting from `code-kit settings --json` with a control, and, once one is
553
+ * changed, a confirmation asking the person's reason before anything is written.
554
+ */
555
+ export function settingsView(rows, pending, { Box, Text, Select, Input, Button }, handlers) {
556
+ const control = (r) => {
557
+ const listed = CHOICES[r.key] ?? (typeof r.default === 'boolean' ? [true, false] : null);
558
+ // A value the list doesn't name (a model given by its id) is still shown, as the one chosen.
559
+ const choices =
560
+ listed && !listed.map(String).includes(String(r.value ?? '')) ? [...listed, r.value] : listed;
561
+ if (choices)
562
+ return Select({
563
+ key: `set-${r.key}`,
564
+ label: '',
565
+ value: String(r.value ?? ''),
566
+ options: choices.map((c) => ({ value: String(c), label: shown(c) })),
567
+ onSelect: (v) => handlers.onChoose(r.key, v === '' ? 'null' : v),
568
+ });
569
+ return Input({
570
+ key: `set-${r.key}`,
571
+ label: '',
572
+ value: String(r.value),
573
+ submitLabel: 'Set',
574
+ onSubmit: (v) => handlers.onChoose(r.key, v.trim()),
575
+ });
576
+ };
577
+ const rowsView = rows.map((r) =>
578
+ Box({
579
+ key: `setting-${r.key}`,
580
+ flexDirection: 'column',
581
+ children: [
582
+ Box({
583
+ flexDirection: 'row',
584
+ columnGap: 2,
585
+ children: [
586
+ Box({ width: 28, children: [Text({ bold: true, children: [r.key] })] }),
587
+ control(r),
588
+ ...(r.value !== r.default
589
+ ? [Text({ dimColor: true, children: [`default ${shown(r.default)}`] })]
590
+ : []),
591
+ ],
592
+ }),
593
+ Text({ dimColor: true, children: [` ${r.about}`] }),
594
+ ],
595
+ }),
596
+ );
597
+ const confirm = pending
598
+ ? [
599
+ Box({
600
+ key: 'settings-confirm',
601
+ flexDirection: 'column',
602
+ borderStyle: 'round',
603
+ paddingX: 1,
604
+ children: [
605
+ Text({
606
+ bold: true,
607
+ children: [
608
+ `Set ${pending.key} to ${shown(pending.value === 'null' ? null : pending.value)}?`,
609
+ ],
610
+ }),
611
+ Text({
612
+ dimColor: true,
613
+ children: [
614
+ 'It goes into .claude/code-kit.json, and the approval log keeps your reason.',
615
+ ],
616
+ }),
617
+ Input({
618
+ key: 'settings-reason',
619
+ label: 'Reason',
620
+ value: pending.reason,
621
+ submitLabel: 'Change it',
622
+ autoFocus: true,
623
+ onInput: handlers.onReason,
624
+ onSubmit: handlers.onConfirm,
625
+ }),
626
+ ...(pending.error ? [Text({ color: 'red', children: [pending.error] })] : []),
627
+ Box({
628
+ flexDirection: 'row',
629
+ columnGap: 2,
630
+ children: [
631
+ Button({
632
+ key: 'settings-ok',
633
+ label: 'Change it',
634
+ onPress: () => handlers.onConfirm(pending.reason),
635
+ }),
636
+ Button({ key: 'settings-cancel', label: 'Cancel', onPress: handlers.onCancel }),
637
+ ],
638
+ }),
639
+ ],
640
+ }),
641
+ ]
642
+ : [];
643
+ return Box({
644
+ flexDirection: 'column',
645
+ rowGap: 1,
646
+ children: [
647
+ Text({ bold: true, color: 'cyan', children: ['HARNESS SETTINGS'] }),
648
+ ...confirm,
649
+ Box({ flexDirection: 'column', children: rowsView }),
650
+ Text({
651
+ dimColor: true,
652
+ children: ['Lanes, paths, layers and protected paths change through /code-kit:init.'],
653
+ }),
654
+ ],
655
+ });
656
+ }
657
+
658
+ // --- usage per lane (USE-1 to USE-4) -----------------------------------------------------------
659
+
660
+ const STORY_ID = /\bST-\d+\b/;
661
+
662
+ /** A model request's tokens: input (cache writes included), output and cache reads. */
663
+ export function tokensOf(usage) {
664
+ return {
665
+ input: (usage?.input_tokens ?? 0) + (usage?.cache_creation_input_tokens ?? 0),
666
+ output: usage?.output_tokens ?? 0,
667
+ cache: usage?.cache_read_input_tokens ?? 0,
668
+ };
669
+ }
670
+
671
+ /**
672
+ * Whom a model request is for (USE-1): the lead when no agent made it; a lane's agent by its type,
673
+ * on the story its brief names or else the story its lane is on; any other agent is background.
674
+ * `agent` is the `$.agent.list()` entry, or null when the list no longer has it.
675
+ */
676
+ export function attribution(agentId, agent, check, state) {
677
+ const laneStory = (name) => state?.lanes?.find((l) => l.name === name)?.story?.id ?? null;
678
+ if (!agentId) return { key: 'lead', lane: LEAD, story: laneStory(LEAD), background: false };
679
+ const lane = Object.entries(check?.lanes ?? {}).find(([, l]) => l.agent === agent?.type)?.[0];
680
+ const named = agent?.description?.match(STORY_ID)?.[0] ?? null;
681
+ if (!lane) return { key: agentId, lane: null, story: named, background: true };
682
+ return { key: agentId, lane, story: named ?? laneStory(lane), background: false };
683
+ }
684
+
685
+ /** The ledger with one request added: entries by agent and story, their tokens summed. */
686
+ export function withUsage(ledger, who, usage) {
687
+ const id = `${who.key}|${who.story ?? ''}`;
688
+ const t = tokensOf(usage);
689
+ const was = ledger[id] ?? { ...who, input: 0, output: 0, cache: 0 };
690
+ return {
691
+ ...ledger,
692
+ [id]: {
693
+ ...was,
694
+ input: was.input + t.input,
695
+ output: was.output + t.output,
696
+ cache: was.cache + t.cache,
697
+ },
698
+ };
699
+ }
700
+
701
+ const total = (e) => e.input + e.output + e.cache;
702
+ const median = (xs) => {
703
+ const s = [...xs].sort((a, b) => a - b);
704
+ const m = Math.floor(s.length / 2);
705
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
706
+ };
707
+
708
+ /** The stories finished: done, or their lane's branch waiting for review. */
709
+ export function finishedStories(state) {
710
+ if (state?.kind !== 'ok') return new Set();
711
+ return new Set([
712
+ ...(state.stories ?? []).filter((s) => s.state === 'done').map((s) => s.id),
713
+ ...state.lanes.filter((l) => l.state === 'in review').map((l) => l.story.id),
714
+ ]);
715
+ }
716
+
717
+ /**
718
+ * The session's usage rolled up (USE-1, USE-2): tokens per lane, per story and for the background
719
+ * agents, and the outliers (USE-3), stories past three times the median of the finished ones once
720
+ * at least three have finished.
721
+ */
722
+ export function usageSummary(ledger, finished = new Set()) {
723
+ const lanes = {};
724
+ const stories = {};
725
+ let background = 0;
726
+ let all = 0;
727
+ for (const e of Object.values(ledger ?? {})) {
728
+ const n = total(e);
729
+ all += n;
730
+ if (e.background) background += n;
731
+ else lanes[e.lane] = (lanes[e.lane] ?? 0) + n;
732
+ if (e.story) stories[e.story] = (stories[e.story] ?? 0) + n;
733
+ }
734
+ const done = Object.entries(stories).filter(([id]) => finished.has(id));
735
+ const usual = done.length >= 3 ? median(done.map(([, n]) => n)) : null;
736
+ const outliers = usual
737
+ ? Object.entries(stories)
738
+ .filter(([, n]) => n > 3 * usual)
739
+ .map(([id, n]) => ({ id, ratio: Math.round((n / usual) * 10) / 10 }))
740
+ .sort((a, b) => b.ratio - a.ratio)
741
+ : [];
742
+ return { lanes, stories, background, total: all, outliers };
743
+ }
744
+
745
+ /** The plan's 5-hour use from the session's limits (null off a subscription), and the pause (USE-4). */
746
+ export function planOf(rateLimits, pauseAtPercent = 80) {
747
+ const percent = (rateLimits ?? []).find((r) => r.kind === 'five_hour')?.percentUsed ?? null;
748
+ return { percent, paused: percent !== null && percent > pauseAtPercent };
749
+ }
750
+
751
+ /** Tokens as a person reads them: 950, 12k, 1.4M. */
752
+ export function tokens(n) {
753
+ if (n < 1000) return String(n);
754
+ if (n < 1e6) return `${Math.round(n / 1000)}k`;
755
+ return `${(n / 1e6).toFixed(1)}M`;
756
+ }
757
+
758
+ /** The plan's use as a bar of ten cells. */
759
+ export function planBar(percent) {
760
+ const full = Math.max(0, Math.min(10, Math.round(percent / 10)));
761
+ return `${'█'.repeat(full)}${'░'.repeat(10 - full)}`;
762
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warren-dean/code-kit",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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",