@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +13 -0
- package/README.md +9 -3
- package/bin/code-kit.mjs +46 -1
- package/hooks/guard-bash.mjs +5 -0
- package/hooks/lib/config.mjs +3 -0
- package/hooks/lib/harness.mjs +131 -0
- package/hooks/mod/hold.mjs +25 -0
- package/hooks/mod/register.mjs +129 -4
- package/hooks/mod/register.test.ts +300 -1
- package/hooks/mod/view.mjs +342 -7
- package/package.json +1 -1
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"name": "code-kit",
|
|
10
10
|
"source": "./",
|
|
11
11
|
"description": "Keeps AI coding agents inside your project's rules, from idea to merged code: spec design, lanes, layer rules, protected paths with an approval log, spec-check and design gates, proof-before-finish checks, dispatch, review, build status and CI verification, configured per project from its docs",
|
|
12
|
-
"version": "0.
|
|
12
|
+
"version": "0.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.
|
|
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.
|
|
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)();
|
package/hooks/guard-bash.mjs
CHANGED
|
@@ -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}`,
|
package/hooks/lib/config.mjs
CHANGED
|
@@ -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
|
+
}
|
package/hooks/mod/register.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|
package/hooks/mod/view.mjs
CHANGED
|
@@ -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 }
|
|
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 {
|
|
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
|
-
/**
|
|
92
|
-
|
|
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({
|
|
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 = {
|
|
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.
|
|
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",
|