@warren-dean/code-kit 0.4.1 → 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.
@@ -0,0 +1,39 @@
1
+ // The lead's send-backs: which commit of a branch a review sent back. Until the branch moves past it,
2
+ // status reads the story as waiting for its lane's fix, not as finished again (and next proposes the
3
+ // fix, not a second review of the same commits). Kept beside the requests, in the main checkout.
4
+ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
6
+ import { projectWorktrees } from './rules.mjs';
7
+
8
+ export const REVIEWS_FILE = '.claude/state/reviews.jsonl';
9
+ const reviewsFile = (root) => join(projectWorktrees(root)[0] ?? root, REVIEWS_FILE);
10
+
11
+ /** Records that the review of `branch` at commit `sha` sent it back, and why. */
12
+ export function recordSentBack(root, branch, sha, reason, now = Date.now()) {
13
+ const file = reviewsFile(root);
14
+ mkdirSync(dirname(file), { recursive: true });
15
+ appendFileSync(
16
+ file,
17
+ `${JSON.stringify({ at: new Date(now).toISOString(), branch, sha, verdict: 'sent back', reason: reason ?? null })}\n`,
18
+ );
19
+ }
20
+
21
+ /** The latest send-back of each branch: { branch: { at, sha, reason } }. */
22
+ export function sentBack(root) {
23
+ const found = {};
24
+ let lines = [];
25
+ try {
26
+ lines = readFileSync(reviewsFile(root), 'utf8').split('\n').filter(Boolean);
27
+ } catch {
28
+ return found;
29
+ }
30
+ for (const line of lines) {
31
+ try {
32
+ const r = JSON.parse(line);
33
+ if (r.verdict === 'sent back' && r.branch && r.sha) found[r.branch] = r;
34
+ } catch {
35
+ // a line being written, or not one of ours
36
+ }
37
+ }
38
+ return found;
39
+ }
@@ -20,12 +20,13 @@ import {
20
20
  readFileSync,
21
21
  readdirSync,
22
22
  realpathSync,
23
+ rmSync,
23
24
  statSync,
24
25
  writeFileSync,
25
26
  } from 'node:fs';
26
27
  import { execFileSync } from 'node:child_process';
27
28
  import { homedir } from 'node:os';
28
- import { dirname, join, relative, resolve } from 'node:path';
29
+ import { basename, dirname, join, relative, resolve } from 'node:path';
29
30
  import { fileURLToPath } from 'node:url';
30
31
  import { DEPENDENCY_APPROVAL, isDependencyFile } from './dependencies.mjs';
31
32
  import { globToRegExp, matchesAny } from './glob.mjs';
@@ -33,6 +34,23 @@ import { globToRegExp, matchesAny } from './glob.mjs';
33
34
  export const APPROVAL_LOG = '.claude/approval-log.jsonl';
34
35
  export const APPROVALS_DIR = '.claude/approvals';
35
36
  export const APPROVAL_MINUTES = 60;
37
+ // A dependency approval lasts until the lane commits the install it allows (the story may resume
38
+ // hours later), and at most a week, so a forgotten one doesn't linger.
39
+ export const DEPENDENCY_APPROVAL_DAYS = 7;
40
+
41
+ /** How long an approval named `name` lasts, in minutes. */
42
+ export const approvalMinutes = (name) =>
43
+ String(name).startsWith(DEPENDENCY_APPROVAL)
44
+ ? DEPENDENCY_APPROVAL_DAYS * 24 * 60
45
+ : APPROVAL_MINUTES;
46
+
47
+ /** How long approvals for `names` last, in words. */
48
+ export function approvalLifetime(names) {
49
+ const list = [names].flat();
50
+ return list.every((n) => String(n).startsWith(DEPENDENCY_APPROVAL))
51
+ ? `until the install is committed, at most ${DEPENDENCY_APPROVAL_DAYS} days`
52
+ : `for ${APPROVAL_MINUTES} minutes`;
53
+ }
36
54
  // Marks a worktree as a lane's, for a person running that lane; the lead creates it, and it is never work.
37
55
  export const LANE_FILE = '.lane';
38
56
  const NEVER_WRITABLE = [`${APPROVALS_DIR}/**`, APPROVAL_LOG];
@@ -80,7 +98,7 @@ export function approvalHowTo(names, actor, root, allows = 'these edits', config
80
98
  : '';
81
99
  return (
82
100
  `${lines.join('\n')}\n` +
83
- `(a person runs it; the reason is recorded in ${APPROVAL_LOG}, and it allows ${allows} for ${whom}, for ${APPROVAL_MINUTES} minutes)${relay}${delegated}`
101
+ `(a person runs it; the reason is recorded in ${APPROVAL_LOG}, and it allows ${allows} for ${whom}, ${approvalLifetime(names)})${relay}${delegated}`
84
102
  );
85
103
  }
86
104
 
@@ -211,7 +229,7 @@ export function owns(actor, rel, config) {
211
229
  function inForce(file) {
212
230
  if (!existsSync(file) || !statSync(file).isFile()) return null;
213
231
  const { mtimeMs } = statSync(file);
214
- if (Date.now() - mtimeMs >= APPROVAL_MINUTES * 60_000) return null;
232
+ if (Date.now() - mtimeMs >= approvalMinutes(basename(file)) * 60_000) return null;
215
233
  const reason = readFileSync(file, 'utf8').trim();
216
234
  return reason ? { reason, grantedAt: new Date(mtimeMs).toISOString() } : null;
217
235
  }
@@ -223,6 +241,16 @@ export function approval(root, name, actor) {
223
241
  return lane ?? inForce(join(dir, name));
224
242
  }
225
243
 
244
+ /** Uses up the approval in force for `name` and `actor`: a dependency approval, once its install is committed. */
245
+ export function useApproval(root, name, actor) {
246
+ const dir = approvalsDir(root);
247
+ for (const file of [
248
+ ...(actor?.kind === 'lane' ? [join(dir, actor.lane, name)] : []),
249
+ join(dir, name),
250
+ ])
251
+ if (inForce(file)) return rmSync(file, { force: true });
252
+ }
253
+
226
254
  const requestsFile = (root) => join(projectWorktrees(root)[0] ?? root, REQUESTS_FILE);
227
255
  const sameRequest = (a, b) =>
228
256
  a.actor === b.actor &&
@@ -306,7 +334,7 @@ export function approvalsInForce(root, now = Date.now()) {
306
334
  }
307
335
  const a = inForce(file);
308
336
  if (!a) continue;
309
- const left = APPROVAL_MINUTES - (now - Date.parse(a.grantedAt)) / 60_000;
337
+ const left = approvalMinutes(name) - (now - Date.parse(a.grantedAt)) / 60_000;
310
338
  found.push({
311
339
  name,
312
340
  ...(lane ? { lane } : {}),
@@ -336,6 +364,33 @@ export function dependencyGrants(root, actor) {
336
364
  .filter((g) => g.reason);
337
365
  }
338
366
 
367
+ /**
368
+ * During a merge, whether `rel` is exactly as the incoming branch (MERGE_HEAD) has it: then its change
369
+ * arrives with that branch's history, approvals and audit-log entries, not from the actor concluding
370
+ * the merge. `staged` compares what will be committed; otherwise the working tree. A file both sides
371
+ * delete counts too. verify still holds the branch to its rules afterwards.
372
+ */
373
+ export function arrivesWithMerge(root, rel, { staged = false } = {}) {
374
+ const run = (...args) => {
375
+ try {
376
+ return execFileSync('git', ['-C', root, ...args], {
377
+ encoding: 'utf8',
378
+ stdio: ['ignore', 'pipe', 'ignore'],
379
+ }).trim();
380
+ } catch {
381
+ return null;
382
+ }
383
+ };
384
+ if (run('rev-parse', '--verify', '--quiet', 'MERGE_HEAD') === null) return false;
385
+ const incoming = run('rev-parse', '--verify', '--quiet', `MERGE_HEAD:${rel}`);
386
+ const mine = staged
387
+ ? run('rev-parse', '--verify', '--quiet', `:${rel}`)
388
+ : existsSync(join(root, rel))
389
+ ? run('hash-object', '--', rel)
390
+ : null;
391
+ return incoming === mine;
392
+ }
393
+
339
394
  /**
340
395
  * Whether a dependency approval lets `actor` change `rel`: a manifest or lockfile no other lane owns,
341
396
  * changed while the actor has a dependency approval in force. Installing a package rewrites them.
@@ -415,6 +470,13 @@ export function laneOf(rel, config) {
415
470
  );
416
471
  }
417
472
 
473
+ /** Who reviews a change to a file any agent may write: the lane an anyActor entry names, else the lead. Null for other files. */
474
+ export function reviewerOf(rel, config) {
475
+ if (!matchesAny(rel, config.anyActor)) return null;
476
+ const named = (config.anyActorReviewers ?? []).find((e) => matchesAny(rel, [e.glob]));
477
+ return named?.reviewer ?? 'lead';
478
+ }
479
+
418
480
  export function ownerOf(rel, config) {
419
481
  const lane = laneOf(rel, config);
420
482
  if (lane) return `${lane} lane / ${config.lanes[lane].agent}`;
@@ -0,0 +1,94 @@
1
+ // What a shell command runs, as distinct from the text it carries. Heredoc bodies and quoted strings
2
+ // are data: a commit message, a card's --text, a grep pattern. So rules that recognise a command
3
+ // (`git commit`, `code-kit merge`) look only at the simple commands it runs, each from its own start.
4
+ // Rules about text the command carries (a commit trailer, a path in a redirect) still read it whole.
5
+
6
+ /** The command with each heredoc's body taken out: the lines after `<<WORD` up to the line `WORD`. */
7
+ export function withoutHeredocs(cmd) {
8
+ const out = [];
9
+ let until = null;
10
+ for (const line of String(cmd).split('\n')) {
11
+ if (until) {
12
+ if (line.trim() === until) until = null;
13
+ continue;
14
+ }
15
+ out.push(line);
16
+ // `<<WORD`, `<<-WORD`, `<<'WORD'` or `<<"WORD"`; not a `<<<` here-string, whose text is on the line.
17
+ const m = line.match(/(?<!<)<<-?\s*(['"]?)([\w.-]+)\1(?!<)/);
18
+ if (m && !line.includes('<<<')) until = m[2];
19
+ }
20
+ return out.join('\n');
21
+ }
22
+
23
+ /** `text` with every quoted character replaced by `_`, the quotes kept: same length, nothing inside to match. */
24
+ export function maskQuotes(text) {
25
+ let out = '';
26
+ let quote = null;
27
+ for (let i = 0; i < text.length; i++) {
28
+ const c = text[i];
29
+ if (quote) {
30
+ if (c === '\\' && quote === '"' && i + 1 < text.length) {
31
+ out += '__';
32
+ i++;
33
+ } else if (c === quote) {
34
+ quote = null;
35
+ out += c;
36
+ } else out += c === '\n' ? '\n' : '_';
37
+ } else if (c === '\\' && i + 1 < text.length) {
38
+ out += '__';
39
+ i++;
40
+ } else {
41
+ if (c === '"' || c === "'") quote = c;
42
+ out += c;
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+
48
+ const SEPARATOR = /[;&|\n()`]/;
49
+ const LEADING =
50
+ /^(?:(?:[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)\s+)|(?:sudo|exec|time|command|nohup|env)\s+|!\s+)+/;
51
+
52
+ /**
53
+ * The simple commands `cmd` runs, in order: split at `;`, `&`, `|`, newlines, parentheses and
54
+ * backticks outside quotes, heredoc bodies left out, each without leading `VAR=value` assignments.
55
+ * Each is `{ text, code }`: `text` as written, `code` with its quoted text masked.
56
+ */
57
+ export function commandsOf(cmd) {
58
+ const text = withoutHeredocs(cmd);
59
+ const mask = maskQuotes(text);
60
+ const found = [];
61
+ let start = 0;
62
+ const take = (end) => {
63
+ const raw = text.slice(start, end);
64
+ const lead = raw.length - raw.trimStart().length;
65
+ const body = raw.trim();
66
+ const skip = body.match(LEADING)?.[0].length ?? 0;
67
+ const from = start + lead + skip;
68
+ const piece = text.slice(from, start + lead + body.length);
69
+ if (piece) found.push({ text: piece, code: mask.slice(from, from + piece.length) });
70
+ };
71
+ for (let i = 0; i < mask.length; i++)
72
+ if (SEPARATOR.test(mask[i])) {
73
+ take(i);
74
+ start = i + 1;
75
+ }
76
+ take(mask.length);
77
+ return found;
78
+ }
79
+
80
+ /** Whether `cmd` runs a command matching `pattern`, which is anchored at the command's start. */
81
+ export function runs(cmd, pattern, { quoted = true } = {}) {
82
+ return commandsOf(cmd).some((c) => pattern.test(quoted ? c.text : c.code));
83
+ }
84
+
85
+ /** The whole command with heredoc bodies out and quoted text masked: for rules that span commands (`curl … | sh`). */
86
+ export function codeOf(cmd) {
87
+ return maskQuotes(withoutHeredocs(cmd));
88
+ }
89
+
90
+ /** `git`, with any `-C dir` and `-c key=value` before its subcommand. */
91
+ export const GIT = String.raw`git\s+(?:-[Cc]\s+(?:"[^"]*"|'[^']*'|\S+)\s+)*`;
92
+
93
+ /** code-kit's command line however it's run: installed, through node (its path quoted or not), or npx. */
94
+ export const CODE_KIT = String.raw`(?:code-kit|node\s+(?:"[^"]*code-kit(?:\.mjs)?"|'[^']*code-kit(?:\.mjs)?'|\S*code-kit(?:\.mjs)?)|npx\s+(?:--yes\s+|-y\s+)?@warren-dean\/code-kit(?:@\S+)?)`;
@@ -6,7 +6,7 @@ import { execFileSync } from 'node:child_process';
6
6
  import { matchesAny } from './glob.mjs';
7
7
  import { layerOf } from './layers.mjs';
8
8
  import { readRequirements, readStories } from './plan.mjs';
9
- import { ownerOf } from './rules.mjs';
9
+ import { ownerOf, reviewerOf } from './rules.mjs';
10
10
 
11
11
  const git = (root, ...args) => {
12
12
  try {
@@ -30,7 +30,7 @@ export function storiesOf(root, rel) {
30
30
  return [...ids];
31
31
  }
32
32
 
33
- /** { path, owner, lane, layer, requirements: [{ id, title, spec, stories }] } for one repository path. */
33
+ /** { path, owner, reviewer, lane, layer, requirements: [{ id, title, spec, stories }] } for one repository path. */
34
34
  export function traceFile(root, rel, config) {
35
35
  const laneName = Object.keys(config.lanes).find((l) => {
36
36
  const { paths, exclude } = config.lanes[l];
@@ -40,6 +40,8 @@ export function traceFile(root, rel, config) {
40
40
  const out = {
41
41
  path: rel,
42
42
  owner: ownerOf(rel, config),
43
+ // For a file any agent may write: who reviews changes to it.
44
+ reviewer: reviewerOf(rel, config),
43
45
  lane: laneName ? { name: laneName, agent: config.lanes[laneName].agent } : null,
44
46
  layer: layer
45
47
  ? { name: layer.name, mayImport: layer.mayImport, denyPackages: layer.denyPackages ?? [] }
@@ -1,7 +1,7 @@
1
1
  // The rules for a branch's changes as a whole, for CI and the lead's review: the hooks only see work
2
2
  // done in Claude Code sessions, so a person's commits or another tool would otherwise go unchecked.
3
3
  // Everything is judged from git (base...HEAD), except the checks, which run in the checkout.
4
- import { execFileSync } from 'node:child_process';
4
+ import { execFileSync, spawnSync } from 'node:child_process';
5
5
  import { existsSync, readFileSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import { BASELINE_FILE, loadBaseline, newProblems } from './baseline.mjs';
@@ -10,7 +10,15 @@ import { CONFIG_FILE } from './config.mjs';
10
10
  import { isDependencyFile } from './dependencies.mjs';
11
11
  import { matchesAny } from './glob.mjs';
12
12
  import { layerProblems } from './layers.mjs';
13
- import { APPROVAL_LOG, SECRETS, laneOf, ownerOf, protectedEntry, screenProblem } from './rules.mjs';
13
+ import {
14
+ APPROVAL_LOG,
15
+ SECRETS,
16
+ laneOf,
17
+ ownerOf,
18
+ owns,
19
+ protectedEntry,
20
+ screenProblem,
21
+ } from './rules.mjs';
14
22
 
15
23
  const git = (root, ...args) =>
16
24
  execFileSync('git', ['-C', root, ...args], {
@@ -31,9 +39,30 @@ function showAt(root, ref, rel) {
31
39
  /** The lane a branch belongs to by its name (`<lane>/<story>`), or null. */
32
40
  export function laneOfBranch(branch, config) {
33
41
  const lane = branch?.split('/')[0];
42
+ if (lane === 'lead') return 'lead';
34
43
  return lane && config.lanes[lane] ? lane : null;
35
44
  }
36
45
 
46
+ /** What verify holds a branch to, in words: a lane's paths, or the lead's. */
47
+ export const heldTo = (lane) => (lane === 'lead' ? "the lead's paths" : `the ${lane} lane`);
48
+
49
+ /**
50
+ * Files changed by commits made on the branch itself (first parents, merges left out): on the lead's
51
+ * branch, what the lead wrote, as distinct from lane work it merged in after review.
52
+ */
53
+ function directChanges(root, from) {
54
+ const out = git(
55
+ root,
56
+ 'log',
57
+ '--first-parent',
58
+ '--no-merges',
59
+ '--format=',
60
+ '--name-only',
61
+ `${from}..HEAD`,
62
+ );
63
+ return new Set(out.split('\n').filter(Boolean));
64
+ }
65
+
37
66
  /** Files changed on this branch since it left `base`. */
38
67
  export function branchChanges(root, base) {
39
68
  return git(root, 'diff', '--name-only', '--no-renames', `${base}...HEAD`)
@@ -96,6 +125,33 @@ function signedByPerson(root, from, file, signedBy) {
96
125
  return messages.length > 0 && messages.every((m) => line.test(m));
97
126
  }
98
127
 
128
+ /**
129
+ * Secrets anywhere in the branch's history, not only in the files as they end up: a secret committed
130
+ * and then deleted is still in the commits that merge. Names that look like secret files, from every
131
+ * commit; and, where gitleaks is installed, what it finds in the commits' content.
132
+ */
133
+ function historySecrets(root, from, changed) {
134
+ const problems = [];
135
+ const touched = git(root, 'log', '--format=', '--name-only', `${from}..HEAD`)
136
+ .split('\n')
137
+ .filter(Boolean);
138
+ for (const f of new Set(touched))
139
+ if (SECRETS(f) && !changed.includes(f))
140
+ problems.push(
141
+ `${f} may hold secrets, and it's in this branch's history even though it's gone now. Rewrite the history without it before it merges.`,
142
+ );
143
+ const scan = spawnSync(
144
+ 'gitleaks',
145
+ ['detect', '--source', root, `--log-opts=${from}..HEAD`, '--redact', '--no-banner'],
146
+ { encoding: 'utf8' },
147
+ );
148
+ if (scan.error?.code !== 'ENOENT' && scan.status === 1)
149
+ problems.push(
150
+ `gitleaks found possible secrets in this branch's commits:\n${`${scan.stdout}${scan.stderr}`.trim()}`,
151
+ );
152
+ return problems;
153
+ }
154
+
99
155
  /**
100
156
  * Every problem with the branch, grouped: { ownership, protected, layers, baseline, design, checks }.
101
157
  * `lane` holds the branch to that lane's paths; `checks: false` leaves the project's checks to CI's
@@ -110,7 +166,9 @@ export function verifyBranch({ root, base, config, lane = null, checks = true })
110
166
  const { entries, problems: logProblems } = logEntries(root, from);
111
167
  found.protected.push(...logProblems);
112
168
  const logged = new Map(entries.map((e) => [e.file, e]));
169
+ const direct = lane === 'lead' ? directChanges(root, from) : null;
113
170
 
171
+ found.ownership.push(...historySecrets(root, from, changed));
114
172
  for (const f of changed) {
115
173
  if (SECRETS(f))
116
174
  found.ownership.push(`${f} may hold secrets; secrets never go in the repository.`);
@@ -122,7 +180,20 @@ export function verifyBranch({ root, base, config, lane = null, checks = true })
122
180
  `${f} is ${guarded.why}, but no approval-log entry records this change. Protected files are committed through Claude Code with code-kit, which logs them.`,
123
181
  );
124
182
  if (matchesAny(f, config.anyActor)) continue;
125
- if (lane) {
183
+ if (lane === 'lead') {
184
+ // The lead's branch is held to the lead's paths for what it changes itself; lane work reaches it
185
+ // by merging the lane's branch, which verify held to that lane.
186
+ if (!direct.has(f)) continue;
187
+ const entry = logged.get(f);
188
+ const approved =
189
+ (guarded && entry?.approval === guarded.approval) ||
190
+ signed ||
191
+ (isDependencyFile(f) && entry?.approval === 'dependency');
192
+ if (!owns({ kind: 'lead' }, f, config) && !approved)
193
+ found.ownership.push(
194
+ `${f}: changed by a commit on the lead's branch, but the lead doesn't own it (owner: ${ownerOf(f, config) ?? 'nobody'}). Lane work comes in by merging the lane's reviewed branch.`,
195
+ );
196
+ } else if (lane) {
126
197
  const { paths, exclude } = config.lanes[lane];
127
198
  const own = matchesAny(f, paths) && !matchesAny(f, exclude);
128
199
  const entry = logged.get(f);
@@ -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
  );