@warren-dean/code-kit 0.3.15 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +34 -0
- package/README.md +46 -26
- package/bin/code-kit.mjs +128 -17
- package/hooks/guard-bash.mjs +79 -29
- package/hooks/hooks.json +1 -0
- package/hooks/lane-audit.mjs +17 -6
- package/hooks/lib/config.mjs +30 -5
- package/hooks/lib/diff.mjs +5 -1
- package/hooks/lib/next.mjs +13 -2
- package/hooks/lib/plan.mjs +18 -7
- package/hooks/lib/registry.mjs +37 -3
- package/hooks/lib/reviews.mjs +39 -0
- package/hooks/lib/rules.mjs +66 -4
- package/hooks/lib/shell.mjs +94 -0
- package/hooks/lib/trace.mjs +4 -2
- package/hooks/lib/verify.mjs +74 -3
- package/hooks/mod/register.mjs +359 -0
- package/hooks/mod/register.test.ts +716 -0
- package/hooks/mod/view.mjs +427 -0
- package/hooks/stop-check.mjs +12 -1
- package/package.json +2 -2
- package/skills/init/SKILL.md +10 -2
- package/skills/review/SKILL.md +9 -1
- package/templates/agent.md +1 -1
|
@@ -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+)?)`;
|
package/hooks/lib/trace.mjs
CHANGED
|
@@ -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 ?? [] }
|
package/hooks/lib/verify.mjs
CHANGED
|
@@ -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 {
|
|
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,359 @@
|
|
|
1
|
+
// The code-kit mod: code-kit in the session, for the person. It draws and acts, but never enforces: the
|
|
2
|
+
// settings hooks beside it enforce the rules, in every session, CI and harness. Claude Code before
|
|
3
|
+
// 2.1.287 doesn't load this module and runs those hooks as before.
|
|
4
|
+
//
|
|
5
|
+
// Everything it shows comes from the code-kit CLI's JSON, run in the session's folder; the mod never
|
|
6
|
+
// imports hooks/lib, which uses node modules a hooks module may not. What it draws is built by the pure
|
|
7
|
+
// functions in view.mjs. It acts only on the person's presses: approving (`approve --via pane`), asking
|
|
8
|
+
// the lead to review, and merging (`merge --person`). No agent reaches those: they are the mod's own
|
|
9
|
+
// buttons, and the hooks refuse both commands from every agent.
|
|
10
|
+
import {
|
|
11
|
+
APPROVE_ID,
|
|
12
|
+
NOT_CODE_KIT,
|
|
13
|
+
PANE_ID,
|
|
14
|
+
RESULT_ID,
|
|
15
|
+
approvalsText,
|
|
16
|
+
agentsAtWork,
|
|
17
|
+
approvePane,
|
|
18
|
+
band,
|
|
19
|
+
bandLines,
|
|
20
|
+
lanesPane,
|
|
21
|
+
parseJson,
|
|
22
|
+
prefilledReason,
|
|
23
|
+
projectState,
|
|
24
|
+
refusalCard,
|
|
25
|
+
refusalView,
|
|
26
|
+
resultPane,
|
|
27
|
+
} from './view.mjs';
|
|
28
|
+
|
|
29
|
+
// What the session knows of the project, read again when it changes.
|
|
30
|
+
let model = { state: null, requests: null, stops: [] };
|
|
31
|
+
let reviewing = new Set(); // stories the person asked the lead to review (ACT-3)
|
|
32
|
+
let reviewTurn = null; // 'next' until the lead's review turn starts, then its id
|
|
33
|
+
let approving = null; // the open Approve… confirmation: { request, reason, error }
|
|
34
|
+
let result = null; // what the last merge reported
|
|
35
|
+
let notice = null; // why the person's last act failed
|
|
36
|
+
let act = null; // the session's actions, made at session start
|
|
37
|
+
const refusals = new Map(); // tool_use_id → the refusal text a hook gave (CARD-1)
|
|
38
|
+
const expanded = new Set(); // cards showing their raw text
|
|
39
|
+
const REFUSALS_KEPT = 200;
|
|
40
|
+
|
|
41
|
+
export function register(on) {
|
|
42
|
+
on('session.start', async ($, e, next) => {
|
|
43
|
+
// A name another command already holds is refused; the rest of the mod goes on without it.
|
|
44
|
+
const unavailable = (name) => (err) =>
|
|
45
|
+
$.ui.log(`code-kit: /${name} isn't available in this session: ${err?.message ?? err}`);
|
|
46
|
+
await $.command
|
|
47
|
+
.register({
|
|
48
|
+
name: 'lanes',
|
|
49
|
+
description: "code-kit's lanes: agents, stories, branches and what's ready",
|
|
50
|
+
immediate: true,
|
|
51
|
+
})
|
|
52
|
+
.catch(unavailable('lanes'));
|
|
53
|
+
await $.command
|
|
54
|
+
.register({
|
|
55
|
+
name: 'approvals',
|
|
56
|
+
description: "code-kit's approval requests waiting, and the approvals in force",
|
|
57
|
+
immediate: true,
|
|
58
|
+
})
|
|
59
|
+
.catch(unavailable('approvals'));
|
|
60
|
+
await $.command
|
|
61
|
+
.register({
|
|
62
|
+
name: 'verify-branch',
|
|
63
|
+
description: 'Run code-kit verify on this branch, checks included',
|
|
64
|
+
})
|
|
65
|
+
.catch(unavailable('verify-branch'));
|
|
66
|
+
const cli = `${$.plugin.root}/bin/code-kit.mjs`;
|
|
67
|
+
const cwd = e.cwd ?? (await $.session.cwd());
|
|
68
|
+
const session = await $.session.id();
|
|
69
|
+
const json = async (...args) => {
|
|
70
|
+
const ran = await $.process.run(['node', cli, ...args, '--json'], { cwd });
|
|
71
|
+
return ran.exitCode === 0 ? parseJson(ran.stdout) : null;
|
|
72
|
+
};
|
|
73
|
+
const stamp = async (path) => {
|
|
74
|
+
const s = await $.fs.stat(`${cwd}/${path}`).catch(() => null);
|
|
75
|
+
return s ? `${path}@${s.mtimeMs}` : `${path}-`;
|
|
76
|
+
};
|
|
77
|
+
const listing = async (path) => {
|
|
78
|
+
const entries = await $.fs.list(`${cwd}/${path}`).catch(() => []);
|
|
79
|
+
const stamps = [];
|
|
80
|
+
for (const entry of entries)
|
|
81
|
+
stamps.push(
|
|
82
|
+
entry.kind === 'dir'
|
|
83
|
+
? await listing(`${path}/${entry.name}`)
|
|
84
|
+
: await stamp(`${path}/${entry.name}`),
|
|
85
|
+
);
|
|
86
|
+
return stamps.join(',');
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
act = {
|
|
90
|
+
// Everything the band and the pane show, read again.
|
|
91
|
+
reload: async () => {
|
|
92
|
+
const ran = await $.process.run(['node', cli, 'check', '--json'], { cwd });
|
|
93
|
+
const check = parseJson(ran.stdout);
|
|
94
|
+
const valid = Boolean(check?.valid);
|
|
95
|
+
const status = valid ? await json('status') : null;
|
|
96
|
+
const nextStep = valid ? await json('next') : null;
|
|
97
|
+
const requests = valid ? await json('requests') : null;
|
|
98
|
+
const stops = valid ? ((await json('stops', '--session', session)) ?? []) : [];
|
|
99
|
+
const atWork = agentsAtWork(await $.agent.list());
|
|
100
|
+
model = {
|
|
101
|
+
state: projectState(check, status, nextStep, atWork),
|
|
102
|
+
requests,
|
|
103
|
+
stops,
|
|
104
|
+
check,
|
|
105
|
+
base: status?.base ?? null,
|
|
106
|
+
};
|
|
107
|
+
$.ui.invalidate('ui.render');
|
|
108
|
+
},
|
|
109
|
+
// What a refresh waits on, read cheaply: branches and HEAD, the config and the plan, requests,
|
|
110
|
+
// approvals and finish checks, and the agents at work. Only a change runs the CLI.
|
|
111
|
+
fingerprint: async () => {
|
|
112
|
+
const refs = await $.process.run(
|
|
113
|
+
['git', 'for-each-ref', '--format=%(HEAD)%(objectname) %(refname)', 'refs/heads'],
|
|
114
|
+
{ cwd },
|
|
115
|
+
);
|
|
116
|
+
const plan = model.check?.docs?.plan;
|
|
117
|
+
return [
|
|
118
|
+
refs.stdout,
|
|
119
|
+
await stamp('.claude/code-kit.json'),
|
|
120
|
+
plan ? await stamp(plan) : '',
|
|
121
|
+
await stamp('.claude/state/requests.jsonl'),
|
|
122
|
+
await listing('.claude/approvals'),
|
|
123
|
+
await listing('.claude/state/stop-blocks'),
|
|
124
|
+
[...agentsAtWork(await $.agent.list())].sort().join(','),
|
|
125
|
+
].join('\n');
|
|
126
|
+
},
|
|
127
|
+
// PANE-1: open the Lanes pane, or close it when it's open.
|
|
128
|
+
lanes: async () => {
|
|
129
|
+
// Asked, not remembered: the person may have closed it with Escape.
|
|
130
|
+
if ((await $.ui.panes()).some((p) => p.id === PANE_ID)) {
|
|
131
|
+
await $.ui.close({ id: PANE_ID });
|
|
132
|
+
return {};
|
|
133
|
+
}
|
|
134
|
+
await act.reload();
|
|
135
|
+
if (model.state.kind === 'none') return { text: NOT_CODE_KIT };
|
|
136
|
+
const placed = await $.ui.open({
|
|
137
|
+
id: PANE_ID,
|
|
138
|
+
title: 'Lanes',
|
|
139
|
+
focus: true,
|
|
140
|
+
closeOnEscape: true,
|
|
141
|
+
});
|
|
142
|
+
// Opened but not drawn yet: it waits for room, and the reason says what seats it.
|
|
143
|
+
if (!placed.isPlaced) return { text: `The Lanes pane is waiting: ${placed.reason}` };
|
|
144
|
+
return {};
|
|
145
|
+
},
|
|
146
|
+
// ACT-1: the confirmation, prefilled from the request.
|
|
147
|
+
approve: async (line) => {
|
|
148
|
+
approving = { request: line.request, reason: prefilledReason(line.request), error: null };
|
|
149
|
+
await $.ui.open({ id: APPROVE_ID, title: 'Approve', focus: true, closeOnEscape: true });
|
|
150
|
+
$.ui.invalidate('ui.render');
|
|
151
|
+
},
|
|
152
|
+
confirmApproval: async (reason) => {
|
|
153
|
+
if (!approving) return;
|
|
154
|
+
if (!reason.trim()) {
|
|
155
|
+
approving = { ...approving, error: 'Give a reason: it goes in the approval log.' };
|
|
156
|
+
$.ui.invalidate('ui.render');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const { request } = approving;
|
|
160
|
+
const ran = await $.process.run(
|
|
161
|
+
[
|
|
162
|
+
'node',
|
|
163
|
+
cli,
|
|
164
|
+
'approve',
|
|
165
|
+
...request.names,
|
|
166
|
+
...(request.lane ? ['--lane', request.lane] : []),
|
|
167
|
+
'--reason',
|
|
168
|
+
reason.trim(),
|
|
169
|
+
'--via',
|
|
170
|
+
'pane',
|
|
171
|
+
],
|
|
172
|
+
{ cwd },
|
|
173
|
+
);
|
|
174
|
+
if (ran.exitCode !== 0) {
|
|
175
|
+
const why = (ran.stderr || ran.stdout).trim().split('\n')[0];
|
|
176
|
+
notice = `Nothing was approved: ${why}`;
|
|
177
|
+
approving = { ...approving, error: notice };
|
|
178
|
+
$.ui.invalidate('ui.render');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
approving = null;
|
|
182
|
+
notice = null;
|
|
183
|
+
await $.ui.close({ id: APPROVE_ID });
|
|
184
|
+
await act.reload();
|
|
185
|
+
},
|
|
186
|
+
cancelApproval: async () => {
|
|
187
|
+
approving = null;
|
|
188
|
+
await $.ui.close({ id: APPROVE_ID });
|
|
189
|
+
},
|
|
190
|
+
// ACT-3: ask the lead, and show the story as being reviewed until its turn ends.
|
|
191
|
+
review: async (line) => {
|
|
192
|
+
reviewing.add(line.story.id);
|
|
193
|
+
reviewTurn = 'next';
|
|
194
|
+
$.ui.invalidate('ui.render');
|
|
195
|
+
await $.prompt.submit({
|
|
196
|
+
text: `Review ${line.story.id} (${line.story.title}): run /code-kit:review ${line.story.branch}.`,
|
|
197
|
+
});
|
|
198
|
+
},
|
|
199
|
+
// ACT-4: confirm, then verify and merge as the person; nothing merges unless verify passes.
|
|
200
|
+
merge: async (line) => {
|
|
201
|
+
const { branch } = line.story;
|
|
202
|
+
const into = model.base ?? 'the base branch';
|
|
203
|
+
const answer = await $.ui
|
|
204
|
+
.ask(
|
|
205
|
+
`Merge ${branch} into ${into}? code-kit verify runs first, checks included, and nothing merges unless it passes.`,
|
|
206
|
+
['Merge', 'Cancel'],
|
|
207
|
+
)
|
|
208
|
+
.catch(() => 'Cancel');
|
|
209
|
+
if (answer !== 'Merge') return;
|
|
210
|
+
const ran = await $.process.run(['node', cli, 'merge', branch, '--person'], {
|
|
211
|
+
cwd,
|
|
212
|
+
timeoutMs: 600000,
|
|
213
|
+
});
|
|
214
|
+
result = {
|
|
215
|
+
ok: ran.exitCode === 0,
|
|
216
|
+
title: ran.exitCode === 0 ? `Merged ${branch}` : `Nothing merged: ${branch}`,
|
|
217
|
+
text: `${ran.stdout}${ran.stderr}`.trim(),
|
|
218
|
+
};
|
|
219
|
+
await $.ui.open({ id: RESULT_ID, title: 'Merge', focus: true, closeOnEscape: true });
|
|
220
|
+
await act.reload();
|
|
221
|
+
},
|
|
222
|
+
// CARD-2: no model call; the transcript shows it.
|
|
223
|
+
approvals: async () => {
|
|
224
|
+
await act.reload();
|
|
225
|
+
if (model.state.kind === 'none') return { text: NOT_CODE_KIT };
|
|
226
|
+
return { text: approvalsText(model.requests) };
|
|
227
|
+
},
|
|
228
|
+
// CARD-3: verify's own report, checks included.
|
|
229
|
+
verify: async () => {
|
|
230
|
+
if (model.state?.kind === 'none') return { text: NOT_CODE_KIT };
|
|
231
|
+
const ran = await $.process.run(['node', cli, 'verify'], { cwd, timeoutMs: 600000 });
|
|
232
|
+
return { text: `${ran.stdout}${ran.stderr}`.trim() };
|
|
233
|
+
},
|
|
234
|
+
dismiss: async () => {
|
|
235
|
+
notice = null;
|
|
236
|
+
$.ui.invalidate('ui.render');
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// PANE-4 and BAND-3: within 2 seconds of a change. With nothing changed, the project is read
|
|
241
|
+
// again every 10 seconds while the pane is open, and every minute otherwise (approvals expire).
|
|
242
|
+
let seen = null;
|
|
243
|
+
let quiet = 0;
|
|
244
|
+
let busy = false;
|
|
245
|
+
$.clock.every(2000, async () => {
|
|
246
|
+
if (busy) return;
|
|
247
|
+
busy = true;
|
|
248
|
+
try {
|
|
249
|
+
const now = await act.fingerprint();
|
|
250
|
+
const paneOpen = (await $.ui.panes()).some((p) => p.id === PANE_ID);
|
|
251
|
+
quiet += 1;
|
|
252
|
+
if (now === seen && quiet < (paneOpen ? 5 : 30)) return;
|
|
253
|
+
quiet = 0;
|
|
254
|
+
await act.reload();
|
|
255
|
+
// Taken again: what it covers (the plan) can change with what was read.
|
|
256
|
+
seen = await act.fingerprint();
|
|
257
|
+
} finally {
|
|
258
|
+
busy = false;
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
return next(e);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const starting = (name) => ({
|
|
265
|
+
text: `code-kit is still starting; try /${name} again in a moment.`,
|
|
266
|
+
});
|
|
267
|
+
on('command.run', { command: 'lanes' }, async ($, e) => (act ? act.lanes() : starting('lanes')));
|
|
268
|
+
on('command.run', { command: 'approvals' }, async ($, e) =>
|
|
269
|
+
act ? act.approvals() : starting('approvals'),
|
|
270
|
+
);
|
|
271
|
+
on('command.run', { command: 'verify-branch' }, async ($, e) =>
|
|
272
|
+
act ? act.verify() : starting('verify-branch'),
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
// CARD-1: the text a code-kit hook refused a call with, kept for the call's result to draw as a card.
|
|
276
|
+
on('tool.call', async ($, e, next) => {
|
|
277
|
+
const res = await next(e);
|
|
278
|
+
const text = res?.deny ?? (res?.isError ? (res.text ?? String(res.result ?? '')) : null);
|
|
279
|
+
if (text && refusalCard(text)) {
|
|
280
|
+
refusals.set(e.tool_use_id, text);
|
|
281
|
+
if (refusals.size > REFUSALS_KEPT) refusals.delete(refusals.keys().next().value);
|
|
282
|
+
}
|
|
283
|
+
return res;
|
|
284
|
+
}).catch(($, e, next) => next(e)); // it only watches: whatever fails here, the call goes on as it would
|
|
285
|
+
// A refused call's text: kept from its tool.call, or the output its row carries (what the model read).
|
|
286
|
+
const refusalOf = (id, output) =>
|
|
287
|
+
refusalCard(refusals.get(id) ?? (typeof output === 'string' ? output : null));
|
|
288
|
+
// Shell calls fold into one line ("Ran 1 shell command"); a group holding a refusal unfolds, so
|
|
289
|
+
// the refused call's row can be drawn as its card.
|
|
290
|
+
on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
|
|
291
|
+
const refused = e.props.calls.some((c) => c.tool_use_id && refusalOf(c.tool_use_id, c.output));
|
|
292
|
+
return refused && !e.props.isExpanded
|
|
293
|
+
? next({ ...e, props: { ...e.props, isExpanded: true } })
|
|
294
|
+
: next(e);
|
|
295
|
+
});
|
|
296
|
+
// The card takes the call's row; the result block beneath a standalone row is then left empty.
|
|
297
|
+
on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
|
|
298
|
+
const card = refusalOf(e.props.tool_use_id, e.props.output);
|
|
299
|
+
if (!card || !act) return next(e);
|
|
300
|
+
return $.ui.resolve(e).Box({ children: [] });
|
|
301
|
+
});
|
|
302
|
+
on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
|
|
303
|
+
const id = e.props.tool_use_id;
|
|
304
|
+
const card = refusalOf(id, e.props.output);
|
|
305
|
+
if (!card || !act) return next(e);
|
|
306
|
+
return refusalView(card, expanded.has(id), $.ui.resolve(e), {
|
|
307
|
+
onApprove: () => act.approve({ request: card.request }),
|
|
308
|
+
onToggle: () => {
|
|
309
|
+
if (expanded.has(id)) expanded.delete(id);
|
|
310
|
+
else expanded.add(id);
|
|
311
|
+
$.ui.invalidate('ui.render');
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
on('turn.start', async ($, e, next) => {
|
|
317
|
+
if (reviewTurn === 'next') reviewTurn = e.turnId;
|
|
318
|
+
return next(e);
|
|
319
|
+
});
|
|
320
|
+
// The lead has reported on the review it was asked for.
|
|
321
|
+
on('turn.complete', async ($, e, next) => {
|
|
322
|
+
if (reviewTurn && reviewTurn === e.turnId) {
|
|
323
|
+
reviewing = new Set();
|
|
324
|
+
reviewTurn = null;
|
|
325
|
+
$.ui.invalidate('ui.render');
|
|
326
|
+
}
|
|
327
|
+
return next(e);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) =>
|
|
331
|
+
lanesPane(model.state, $.ui.resolve(e)),
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
// BAND-2: absent when nothing waits. Its lines go above whatever else the band holds (another
|
|
335
|
+
// plugin's lines, such as Context Graph's, or Claude Code's own), which it keeps.
|
|
336
|
+
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
337
|
+
const lines = bandLines({ ...model, reviewing, notice });
|
|
338
|
+
if (!lines.length || !act) return next(e);
|
|
339
|
+
const ours = band(lines, $.ui.resolve(e), (id, line) => act[id](line));
|
|
340
|
+
const below = await next(e);
|
|
341
|
+
return below ? $.ui.resolve(e).Box({ flexDirection: 'column', children: [ours, below] }) : ours;
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
on('ui.render', { component: 'Pane', requestId: APPROVE_ID }, async ($, e) => {
|
|
345
|
+
if (!approving) return resultPane(null, $.ui.resolve(e));
|
|
346
|
+
return approvePane(approving, $.ui.resolve(e), {
|
|
347
|
+
onInput: (value) => {
|
|
348
|
+
approving = { ...approving, reason: value };
|
|
349
|
+
$.ui.invalidate('ui.render');
|
|
350
|
+
},
|
|
351
|
+
onSubmit: (value) => act.confirmApproval(value),
|
|
352
|
+
onCancel: () => act.cancelApproval(),
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
on('ui.render', { component: 'Pane', requestId: RESULT_ID }, async ($, e) =>
|
|
357
|
+
resultPane(result, $.ui.resolve(e)),
|
|
358
|
+
);
|
|
359
|
+
}
|