@warren-dean/code-kit 0.3.15 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,418 @@
1
+ // What the code-kit mod shows, as pure functions of the CLI's JSON: the mod's hooks run the CLI and
2
+ // pass its output here, and pass the elements `$.ui.resolve` gives them. Nothing here touches the mods
3
+ // API, files or processes, so it is tested with node as well as with `claude plugin test`.
4
+
5
+ export const PANE_ID = 'code-kit-lanes';
6
+ export const NOT_CODE_KIT = "This project doesn't use code-kit.";
7
+ const LEAD = 'lead';
8
+
9
+ /** A CLI run's stdout as JSON, or null when it printed none. */
10
+ export function parseJson(stdout) {
11
+ try {
12
+ return JSON.parse(stdout);
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ /** The agent statuses that mean an agent is at work in the session now. */
19
+ const AT_WORK = new Set(['pending', 'running', 'waiting']);
20
+
21
+ /** The subagent types at work, from `$.agent.list()`. */
22
+ export function agentsAtWork(agents) {
23
+ return new Set((agents ?? []).filter((a) => AT_WORK.has(a.status)).map((a) => a.type));
24
+ }
25
+
26
+ /** The stories `code-kit next --json` says can start now: dispatched to lanes, or the lead's own. */
27
+ export function readyFrom(next) {
28
+ if (!next) return [];
29
+ const steps = [next, ...(next.then ?? [])].filter((s) => ['dispatch', 'lead'].includes(s.step));
30
+ return [...new Set(steps.flatMap((s) => (s.args ?? '').split(/\s+/).filter(Boolean)))];
31
+ }
32
+
33
+ /**
34
+ * One lane's row (PANE-2): the story it's on, its branch and its state.
35
+ * building a branch for the story and, if it has commits, the lane's agent still at work on it;
36
+ * in review commits on the branch (status's `review`) and the agent no longer at work;
37
+ * blocked the lane's next story waits on another;
38
+ * idle none of these.
39
+ * `status` can't tell a finished branch from one still being built, so the agent at work decides.
40
+ */
41
+ function laneRow(name, agent, stories, atWork) {
42
+ const own = stories.filter((s) => s.lane === name);
43
+ const active = Boolean(agent && atWork.has(agent));
44
+ const story = own.find((s) => ['in progress', 'review'].includes(s.state));
45
+ if (story) {
46
+ const state = story.state === 'review' && !active ? 'in review' : 'building';
47
+ return { name, agent, active, story, branch: story.branch, state };
48
+ }
49
+ const waiting = own.find((s) => ['blocked', 'ready', 'todo'].includes(s.state));
50
+ if (waiting?.state === 'blocked')
51
+ return { name, agent, active, story: waiting, branch: null, state: 'blocked' };
52
+ return { name, agent, active, story: null, branch: null, state: 'idle' };
53
+ }
54
+
55
+ /**
56
+ * Where the project stands, from `code-kit check --json` and, when the config is valid, `status
57
+ * --json` and `next --json` (each null when the config names no plan, or the run failed), and the
58
+ * subagent types at work:
59
+ * { kind: 'none' } no .claude/code-kit.json
60
+ * { kind: 'invalid', problems } a config that doesn't validate
61
+ * { kind: 'ok', lanes, ready, planGaps } a row per lane, what's ready, the plan's gaps (or null)
62
+ */
63
+ export function projectState(check, status, next = null, atWork = new Set()) {
64
+ if (!check || check.exists === false) return { kind: 'none' };
65
+ if (!check.valid) return { kind: 'invalid', problems: check.problems ?? [] };
66
+ const stories = status?.stories ?? [];
67
+ const lanes = [
68
+ laneRow(LEAD, null, stories, atWork),
69
+ ...Object.entries(check.lanes ?? {}).map(([name, l]) =>
70
+ laneRow(name, l.agent, stories, atWork),
71
+ ),
72
+ ];
73
+ const ready = readyFrom(next).map((id) => {
74
+ const story = stories.find((s) => s.id === id);
75
+ return { id, title: story?.title ?? '', lane: story?.lane ?? null };
76
+ });
77
+ return { kind: 'ok', lanes, ready, planGaps: status ? (status.problems ?? []).length : null };
78
+ }
79
+
80
+ const STATE_COLOR = { building: 'blue', 'in review': 'yellow', blocked: 'red', idle: undefined };
81
+
82
+ /** The Lanes pane's body, drawn with the elements `$.ui.resolve(e)` returned. */
83
+ export function lanesPane(state, { Box, Text }) {
84
+ const text = (value, style = {}) => Text({ ...style, children: [value] });
85
+ if (!state || state.kind === 'none') return text(NOT_CODE_KIT, { dimColor: true });
86
+ if (state.kind === 'invalid')
87
+ return text('The config is invalid: run code-kit check.', { color: 'red' });
88
+ const width = Math.max(...state.lanes.map((l) => l.name.length));
89
+ const rows = state.lanes.map((l) =>
90
+ Box({
91
+ key: `lane-${l.name}`,
92
+ flexDirection: 'column',
93
+ children: [
94
+ Box({
95
+ flexDirection: 'row',
96
+ columnGap: 2,
97
+ children: [
98
+ text(l.active ? '●' : ' ', { color: 'green' }),
99
+ text(l.name.padEnd(width), { bold: true }),
100
+ text(l.state.padEnd(9), STATE_COLOR[l.state] ? { color: STATE_COLOR[l.state] } : {}),
101
+ text(l.agent ?? 'the main session', { dimColor: true }),
102
+ ],
103
+ }),
104
+ ...(l.story
105
+ ? [
106
+ text(
107
+ ` ${l.story.id} ${l.story.title}${l.branch ? ` · ${l.branch}` : ''}${
108
+ l.state === 'blocked' ? ` · waits on ${l.story.waitingOn.join(', ')}` : ''
109
+ }`,
110
+ { dimColor: true, wrap: 'truncate-end' },
111
+ ),
112
+ ]
113
+ : []),
114
+ ],
115
+ }),
116
+ );
117
+ const notes = [];
118
+ if (state.planGaps)
119
+ notes.push(
120
+ text(`The plan has ${state.planGaps} gap(s): run code-kit status.`, { color: 'yellow' }),
121
+ );
122
+ const ready = state.ready.length
123
+ ? [
124
+ text('Ready', { bold: true }),
125
+ ...state.ready.map((r) =>
126
+ Box({
127
+ key: `ready-${r.id}`,
128
+ flexDirection: 'row',
129
+ columnGap: 2,
130
+ children: [
131
+ text(r.id),
132
+ text(r.title, { wrap: 'truncate-end' }),
133
+ text(r.lane === LEAD ? "the lead's own" : (r.lane ?? ''), { dimColor: true }),
134
+ ],
135
+ }),
136
+ ),
137
+ ]
138
+ : [text('Nothing is ready to start.', { dimColor: true })];
139
+ return Box({
140
+ flexDirection: 'column',
141
+ rowGap: 1,
142
+ children: [
143
+ ...notes,
144
+ Box({ flexDirection: 'column', children: rows }),
145
+ Box({ flexDirection: 'column', children: ready }),
146
+ ],
147
+ });
148
+ }
149
+
150
+ // --- the band, and the person's acts -----------------------------------------------------------
151
+
152
+ export const APPROVE_ID = 'code-kit-approve';
153
+ export const RESULT_ID = 'code-kit-result';
154
+
155
+ /** An approval name as a person reads it: a dependency approval by its package. */
156
+ export function approvalLabel(name) {
157
+ return name.startsWith('dep-') ? name.slice(4).replaceAll('+', '/') : name;
158
+ }
159
+
160
+ /** A command as a person reads it: the part before its pipes, redirects and chained commands. */
161
+ export function commandItself(command) {
162
+ return String(command)
163
+ .split(/\s+(?:\d?>>?&?\d*|\|\|?|&&|;)(?:\s|$)/)[0]
164
+ .trim();
165
+ }
166
+
167
+ /** The reason an Approve… confirmation starts with (ACT-1). */
168
+ export function prefilledReason(request) {
169
+ return `Approve ${request.names.map(approvalLabel).join(', ')} for ${
170
+ request.lane ? `the ${request.lane} lane` : 'any agent'
171
+ }: ${commandItself(request.what)}`;
172
+ }
173
+
174
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
175
+
176
+ /**
177
+ * The band's lines (BAND-2): one per kind of thing waiting, each with its actions, and none when
178
+ * nothing waits. `reviewing` holds the stories the person has asked the lead to review (ACT-3);
179
+ * `notice` is why the person's last act failed, until they dismiss it.
180
+ */
181
+ export function bandLines({ state, requests, stops, reviewing = new Set(), notice = null }) {
182
+ const lines = [];
183
+ if (notice)
184
+ lines.push({ kind: 'notice', text: notice, actions: [{ id: 'dismiss', label: 'Dismiss' }] });
185
+ if (!state || state.kind !== 'ok') return lines;
186
+ const open = requests?.open ?? [];
187
+ if (open.length)
188
+ lines.push({
189
+ kind: 'approvals',
190
+ text: `${plural(open.length, 'approval', 'approvals')} waiting`,
191
+ request: open[0],
192
+ actions: [{ id: 'approve', label: 'Approve…' }],
193
+ });
194
+ const inReview = state.lanes.filter((l) => l.state === 'in review').map((l) => l.story);
195
+ if (inReview.length) {
196
+ const [story] = inReview;
197
+ const ids = inReview.map((s) => s.id).join(', ');
198
+ lines.push(
199
+ reviewing.has(story.id)
200
+ ? { kind: 'review', text: `${story.id} being reviewed`, story, actions: [] }
201
+ : {
202
+ kind: 'review',
203
+ text: `${ids} ready for review`,
204
+ story,
205
+ actions: [
206
+ { id: 'review', label: 'Review' },
207
+ { id: 'merge', label: 'Merge' },
208
+ ],
209
+ },
210
+ );
211
+ }
212
+ if (stops?.length)
213
+ lines.push({
214
+ kind: 'finish',
215
+ text: `Finish check failing: ${stops[0].title}${stops.length > 1 ? ` (and ${stops.length - 1} more)` : ''}`,
216
+ actions: [{ id: 'lanes', label: 'Lanes' }],
217
+ });
218
+ // BAND-4: a digit for every action, in the order they're drawn.
219
+ let digit = 0;
220
+ for (const line of lines)
221
+ for (const action of line.actions) action.hotkey = digit < 9 ? String(++digit) : undefined;
222
+ return lines;
223
+ }
224
+
225
+ /** The band, drawn with the elements `$.ui.resolve(e)` returned; `onAction(id, line)` acts. */
226
+ export function band(lines, { Box, Text, Button }, onAction) {
227
+ const COLOR = { notice: 'red', approvals: 'yellow', review: 'blue', finish: 'red' };
228
+ return Box({
229
+ flexDirection: 'column',
230
+ children: lines.map((line) =>
231
+ Box({
232
+ key: `band-${line.kind}`,
233
+ flexDirection: 'row',
234
+ columnGap: 2,
235
+ children: [
236
+ Text({ color: COLOR[line.kind], children: ['code-kit'] }),
237
+ Text({ children: [line.text] }),
238
+ ...line.actions.map((a) =>
239
+ Button({
240
+ key: `band-${a.id}`,
241
+ label: a.hotkey ? `${a.hotkey} ${a.label}` : a.label,
242
+ hotkey: a.hotkey,
243
+ onPress: () => onAction(a.id, line),
244
+ }),
245
+ ),
246
+ ],
247
+ }),
248
+ ),
249
+ });
250
+ }
251
+
252
+ /** The Approve… confirmation (ACT-1): what it grants, to whom, for how long, and the reason. */
253
+ export function approvePane(
254
+ approving,
255
+ { Box, Text, Input, Button },
256
+ { onInput, onSubmit, onCancel },
257
+ ) {
258
+ const { request, reason, error } = approving;
259
+ return Box({
260
+ flexDirection: 'column',
261
+ rowGap: 1,
262
+ children: [
263
+ Text({ bold: true, children: [`Approve ${request.names.join(', ')}`] }),
264
+ Text({
265
+ children: [
266
+ `For ${request.lane ? `the ${request.lane} lane` : 'any agent'}, for 60 minutes. Asked for: ${request.what}`,
267
+ ],
268
+ }),
269
+ Input({
270
+ key: 'approve-reason',
271
+ label: 'Reason',
272
+ value: reason,
273
+ submitLabel: 'Approve',
274
+ autoFocus: true,
275
+ onInput,
276
+ onSubmit,
277
+ }),
278
+ ...(error ? [Text({ color: 'red', children: [error] })] : []),
279
+ Box({
280
+ flexDirection: 'row',
281
+ columnGap: 2,
282
+ children: [
283
+ Button({ key: 'approve-confirm', label: 'Approve', onPress: () => onSubmit(reason) }),
284
+ Button({ key: 'approve-cancel', label: 'Cancel', onPress: onCancel }),
285
+ ],
286
+ }),
287
+ ],
288
+ });
289
+ }
290
+
291
+ /** What a merge (or another act run through the CLI) reported. */
292
+ export function resultPane(result, { Box, Text }) {
293
+ if (!result) return Text({ dimColor: true, children: ['Nothing to show.'] });
294
+ return Box({
295
+ flexDirection: 'column',
296
+ rowGap: 1,
297
+ children: [
298
+ Text({ bold: true, color: result.ok ? 'green' : 'red', children: [result.title] }),
299
+ Text({ children: [result.text.slice(-9000) || '(no output)'] }),
300
+ ],
301
+ });
302
+ }
303
+
304
+ // --- refusal cards and the commands ------------------------------------------------------------
305
+
306
+ const APPROVAL_LINE =
307
+ /^\s*! echo "<what you are approving>" > .*?\.claude\/approvals\/(?:([^/\s]+)\/)?([^/\s]+)\s*$/gm;
308
+
309
+ /**
310
+ * A code-kit refusal read back from the text its hooks print (CARD-1): what was refused, the rule and
311
+ * why, what to do, and the approval that would allow it, if any. Null for text it doesn't recognise,
312
+ * which Claude Code then draws as it would.
313
+ */
314
+ export function refusalCard(raw) {
315
+ const at = typeof raw === 'string' ? raw.indexOf('Blocked: ') : -1;
316
+ if (at < 0) return null;
317
+ const text = raw.slice(at + 'Blocked: '.length).trim();
318
+ const [first, ...rest] = text.split('\n');
319
+ const approvals = [...text.matchAll(APPROVAL_LINE)];
320
+ const command = text.match(/^Command: (.+)$/m)?.[1] ?? null;
321
+ const request = (what) =>
322
+ approvals.length
323
+ ? { names: approvals.map((m) => m[2]), lane: approvals[0][1] ?? null, what }
324
+ : null;
325
+ let m = first.match(/^The (.+?) may not write (\S+?)(?: \(owned by the (.+)\))?\.$/);
326
+ if (m)
327
+ return {
328
+ kind: 'write',
329
+ title: `Write refused: ${m[2]}`,
330
+ why: m[3] ? `It's owned by the ${m[3]}, not the ${m[1]}.` : `Nobody may write it.`,
331
+ todo: rest.find((l) => l.trim()) ?? '',
332
+ request: null,
333
+ raw: text,
334
+ };
335
+ m = first.match(/^(\S+) is (.+) and needs a person's approval\./);
336
+ if (m)
337
+ return {
338
+ kind: 'approval',
339
+ title: `Approval needed: ${m[1]}`,
340
+ why: `It's ${m[2]}.`,
341
+ todo: 'A person approves it, here or with the `!` command, for 60 minutes.',
342
+ request: request(`write ${m[1]}`),
343
+ raw: text,
344
+ };
345
+ m = first.match(/^a new dependency \((.+?)\) needs a person's approval\./);
346
+ if (m)
347
+ return {
348
+ kind: 'dependency',
349
+ title: `Install refused: ${m[1]}`,
350
+ why: "A new dependency needs a person's approval.",
351
+ todo: 'A person approves it, here or with the `!` command, for 60 minutes.',
352
+ request: request(command ?? `install ${m[1]}`),
353
+ raw: text,
354
+ };
355
+ if (command)
356
+ return {
357
+ kind: 'command',
358
+ title: 'Command refused',
359
+ why: first,
360
+ todo: rest.filter((l) => l.trim() && !l.startsWith('Command: ')).join(' '),
361
+ command,
362
+ request: request(command),
363
+ raw: text,
364
+ };
365
+ return null;
366
+ }
367
+
368
+ /** A refusal card (CARD-1), with Approve… where an approval would allow it and the raw text a press away. */
369
+ export function refusalView(card, expanded, { Box, Text, Button }, { onApprove, onToggle }) {
370
+ return Box({
371
+ flexDirection: 'column',
372
+ borderStyle: 'round',
373
+ paddingX: 1,
374
+ children: [
375
+ Text({ bold: true, color: 'red', children: [card.title] }),
376
+ Text({ children: [card.why] }),
377
+ ...(card.command ? [Text({ dimColor: true, children: [card.command] })] : []),
378
+ ...(card.todo ? [Text({ dimColor: true, children: [card.todo] })] : []),
379
+ Box({
380
+ flexDirection: 'row',
381
+ columnGap: 2,
382
+ children: [
383
+ ...(card.request
384
+ ? [Button({ key: 'card-approve', label: 'Approve…', onPress: onApprove })]
385
+ : []),
386
+ Button({
387
+ key: 'card-raw',
388
+ label: expanded ? 'Hide the text' : 'Show the text',
389
+ plain: true,
390
+ onPress: onToggle,
391
+ }),
392
+ ],
393
+ }),
394
+ ...(expanded ? [Text({ dimColor: true, children: [card.raw] })] : []),
395
+ ],
396
+ });
397
+ }
398
+
399
+ /** `/approvals` (CARD-2): the open requests and the approvals in force, from `requests --json`. */
400
+ export function approvalsText(requests) {
401
+ const open = requests?.open ?? [];
402
+ const inForce = requests?.inForce ?? [];
403
+ const lines = [
404
+ open.length ? `${plural(open.length, 'request', 'requests')} waiting:` : 'No requests waiting.',
405
+ ...open.map(
406
+ (r) =>
407
+ ` ${r.names.map(approvalLabel).join(', ')} for ${r.lane ? `the ${r.lane} lane` : 'any agent'}: ${r.what}`,
408
+ ),
409
+ inForce.length
410
+ ? `${plural(inForce.length, 'approval', 'approvals')} in force:`
411
+ : 'No approvals in force.',
412
+ ...inForce.map(
413
+ (a) =>
414
+ ` ${approvalLabel(a.name)} for ${a.lane ? `the ${a.lane} lane` : 'any agent'}, ${a.minutesLeft} min left: ${a.reason}`,
415
+ ),
416
+ ];
417
+ return lines.join('\n');
418
+ }
@@ -39,7 +39,18 @@ const blockCounted = (message) => {
39
39
  const count = last.message === message ? last.count + 1 : 1;
40
40
  if (count > MAX_REPEATS) passed();
41
41
  mkdirSync(join(memory, '..'), { recursive: true });
42
- writeFileSync(memory, JSON.stringify({ message, count }));
42
+ // Also what `code-kit stops` shows the person: whose finish was refused, when, and why.
43
+ writeFileSync(
44
+ memory,
45
+ JSON.stringify({
46
+ message,
47
+ count,
48
+ session: input.session_id ?? null,
49
+ agent: input.agent_id ?? null,
50
+ agentType: input.agent_type ?? null,
51
+ at: new Date().toISOString(),
52
+ }),
53
+ );
43
54
  const final =
44
55
  count === MAX_REPEATS
45
56
  ? `\n\n(code-kit has reported this ${count} times without progress; it won't block on it again. Tell the lead what is still failing.)`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warren-dean/code-kit",
3
- "version": "0.3.15",
3
+ "version": "0.4.1",
4
4
  "description": "Keeps AI coding agents inside your project's rules: lanes, layer rules, protected paths and proof before finish, enforced by Claude Code hooks and in CI",
5
5
  "license": "MIT",
6
6
  "author": "Warren Langeveldt",
@@ -38,6 +38,6 @@
38
38
  "node": ">=22"
39
39
  },
40
40
  "scripts": {
41
- "test": "node --test test/units.test.mjs && node test/hooks.test.mjs"
41
+ "test": "node --test test/units.test.mjs && node test/hooks.test.mjs && node test/mod.mjs"
42
42
  }
43
43
  }