@warren-dean/code-kit 0.5.0 → 0.7.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 +50 -0
- package/README.md +26 -10
- package/bin/code-kit.mjs +398 -39
- package/hooks/guard-bash.mjs +35 -14
- package/hooks/lane-audit.mjs +44 -1
- package/hooks/lib/adapters/context-graph.mjs +75 -6
- package/hooks/lib/adapters/index.mjs +18 -0
- package/hooks/lib/config.mjs +46 -2
- package/hooks/lib/dependencies.mjs +26 -9
- package/hooks/lib/harness.mjs +131 -0
- package/hooks/lib/next.mjs +34 -3
- package/hooks/lib/plan.mjs +29 -0
- package/hooks/lib/queue.mjs +75 -0
- package/hooks/lib/reviews.mjs +18 -0
- package/hooks/lib/rules.mjs +8 -4
- package/hooks/lib/shell.mjs +1 -1
- package/hooks/lib/timeline.mjs +77 -0
- package/hooks/mod/art.mjs +200 -0
- package/hooks/mod/hold.mjs +25 -0
- package/hooks/mod/loop.mjs +68 -0
- package/hooks/mod/panes.mjs +943 -0
- package/hooks/mod/register.mjs +1102 -14
- package/hooks/mod/register.test.ts +1747 -13
- package/hooks/mod/review.mjs +102 -0
- package/hooks/mod/view.mjs +763 -36
- package/package.json +1 -1
- package/skills/init/SKILL.md +5 -4
- package/skills/review/SKILL.md +8 -1
package/hooks/mod/view.mjs
CHANGED
|
@@ -7,6 +7,8 @@ export const NOT_CODE_KIT = "This project doesn't use code-kit.";
|
|
|
7
7
|
const LEAD = 'lead';
|
|
8
8
|
|
|
9
9
|
/** A CLI run's stdout as JSON, or null when it printed none. */
|
|
10
|
+
import { BAR, POSE_GLYPH, rasterCells, runs, sprite, spriteSvg, timelineSvg } from './art.mjs';
|
|
11
|
+
|
|
10
12
|
export function parseJson(stdout) {
|
|
11
13
|
try {
|
|
12
14
|
return JSON.parse(stdout);
|
|
@@ -61,7 +63,8 @@ function laneRow(name, agent, stories, atWork) {
|
|
|
61
63
|
* subagent types at work:
|
|
62
64
|
* { kind: 'none' } no .claude/code-kit.json
|
|
63
65
|
* { kind: 'invalid', problems } a config that doesn't validate
|
|
64
|
-
* { kind: 'ok', lanes, ready, planGaps }
|
|
66
|
+
* { kind: 'ok', lanes, ready, planGaps, stories } a row per lane, what's ready, the plan's gaps (or
|
|
67
|
+
* null), and each story's id, lane and state
|
|
65
68
|
*/
|
|
66
69
|
export function projectState(check, status, next = null, atWork = new Set()) {
|
|
67
70
|
if (!check || check.exists === false) return { kind: 'none' };
|
|
@@ -77,52 +80,235 @@ export function projectState(check, status, next = null, atWork = new Set()) {
|
|
|
77
80
|
const story = stories.find((s) => s.id === id);
|
|
78
81
|
return { id, title: story?.title ?? '', lane: story?.lane ?? null };
|
|
79
82
|
});
|
|
80
|
-
return {
|
|
83
|
+
return {
|
|
84
|
+
kind: 'ok',
|
|
85
|
+
lanes,
|
|
86
|
+
ready,
|
|
87
|
+
planGaps: status ? (status.problems ?? []).length : null,
|
|
88
|
+
stories: stories.map((s) => ({
|
|
89
|
+
id: s.id,
|
|
90
|
+
title: s.title,
|
|
91
|
+
lane: s.lane,
|
|
92
|
+
state: s.state,
|
|
93
|
+
branch: s.branch ?? null,
|
|
94
|
+
worktree: s.worktree ?? null,
|
|
95
|
+
requirements: s.requirements ?? [],
|
|
96
|
+
waitingOn: s.waitingOn ?? [],
|
|
97
|
+
})),
|
|
98
|
+
};
|
|
81
99
|
}
|
|
82
100
|
|
|
83
|
-
const STATE_COLOR = {
|
|
101
|
+
export const STATE_COLOR = {
|
|
84
102
|
building: 'blue',
|
|
103
|
+
working: 'green',
|
|
85
104
|
'in review': 'yellow',
|
|
86
105
|
'sent back': 'magenta',
|
|
87
106
|
blocked: 'red',
|
|
88
107
|
idle: undefined,
|
|
89
108
|
};
|
|
90
109
|
|
|
91
|
-
/**
|
|
92
|
-
|
|
110
|
+
/**
|
|
111
|
+
* A lane's state as the pane shows it: `queued` while its branch waits in the merge queue (MQ-4), and
|
|
112
|
+
* `working` while its agent (or the lead, mid-turn) is at work on nothing the plan names.
|
|
113
|
+
*/
|
|
114
|
+
export function shownState(lane, queue = []) {
|
|
115
|
+
if (lane.state === 'idle' && lane.active) return 'working';
|
|
116
|
+
const waiting = queue.some(
|
|
117
|
+
(e) => e.branch === lane.branch && ['waiting', 'merging'].includes(e.state),
|
|
118
|
+
);
|
|
119
|
+
return lane.branch && waiting ? 'queued' : lane.state;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The Lanes tab's body, drawn with the elements `$.ui.resolve(e)` returned; `usage` is
|
|
124
|
+
* usageSummary's, `plan` planOf's (USE-2), either null before there is any. `lanesOf` narrows and
|
|
125
|
+
* marks the rows (VIEW-1, VIEW-6): { filter, search, selected }, and `live` is each lane's last tool
|
|
126
|
+
* call (VIEW-4): { [lane]: { tool, ago, level } }, level null, 'quiet' or 'stalled'.
|
|
127
|
+
*/
|
|
128
|
+
export function lanesPane(
|
|
129
|
+
state,
|
|
130
|
+
els,
|
|
131
|
+
usage = null,
|
|
132
|
+
plan = null,
|
|
133
|
+
loop = null,
|
|
134
|
+
{
|
|
135
|
+
filter = null,
|
|
136
|
+
search = '',
|
|
137
|
+
selected = null,
|
|
138
|
+
live = {},
|
|
139
|
+
art = null,
|
|
140
|
+
combined = null,
|
|
141
|
+
detail = null,
|
|
142
|
+
} = {},
|
|
143
|
+
) {
|
|
144
|
+
const { Box, Text } = els;
|
|
93
145
|
const text = (value, style = {}) => Text({ ...style, children: [value] });
|
|
94
146
|
if (!state || state.kind === 'none') return text(NOT_CODE_KIT, { dimColor: true });
|
|
95
147
|
if (state.kind === 'invalid')
|
|
96
148
|
return text('The config is invalid: run code-kit check.', { color: 'red' });
|
|
97
149
|
const width = Math.max(...state.lanes.map((l) => l.name.length));
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
150
|
+
// VIEW-3: the lane's character: half-block cells in the terminal, SVG on Desktop, its glyph elsewhere.
|
|
151
|
+
const character = (l) => {
|
|
152
|
+
const pose = art.poses[l.name] ?? 'idle';
|
|
153
|
+
const lead = l.name === LEAD;
|
|
154
|
+
if (art.surface === 'terminal' && els.Raster)
|
|
155
|
+
return els.Raster({
|
|
156
|
+
key: `char-${l.name}`,
|
|
157
|
+
columns: 6,
|
|
158
|
+
rows: 3,
|
|
159
|
+
cells: rasterCells(sprite(pose, art.frame, art.tints[l.name], { lead })),
|
|
160
|
+
});
|
|
161
|
+
if (els.Svg)
|
|
162
|
+
return els.Svg({
|
|
163
|
+
source: spriteSvg(pose, art.tints[l.name], { lead }),
|
|
164
|
+
alt: `${l.name}: ${pose}`,
|
|
165
|
+
width: 24,
|
|
166
|
+
height: 24,
|
|
167
|
+
...(pose === 'working' ? { isInteractive: true } : {}),
|
|
168
|
+
});
|
|
169
|
+
return text(POSE_GLYPH[pose]);
|
|
170
|
+
};
|
|
171
|
+
// VIEW-2: the lane's story on the shared time axis.
|
|
172
|
+
const bar = (l) => {
|
|
173
|
+
const cells = art?.bars?.[l.name];
|
|
174
|
+
if (!cells) return [];
|
|
175
|
+
if (art.surface !== 'terminal' && els.Svg)
|
|
176
|
+
return [
|
|
177
|
+
els.Svg({
|
|
178
|
+
source: timelineSvg([{ cells }], { width: art.svgWidth ?? 480 }),
|
|
179
|
+
alt: `${l.name}'s timeline`,
|
|
112
180
|
}),
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
181
|
+
];
|
|
182
|
+
return [
|
|
183
|
+
Box({
|
|
184
|
+
key: `bar-${l.name}`,
|
|
185
|
+
flexDirection: 'row',
|
|
186
|
+
children: [
|
|
187
|
+
text(' '),
|
|
188
|
+
...runs(cells).map((run) =>
|
|
189
|
+
text(
|
|
190
|
+
BAR[run.kind].glyph.repeat(run.n),
|
|
191
|
+
BAR[run.kind].color ? { color: BAR[run.kind].color } : {},
|
|
192
|
+
),
|
|
193
|
+
),
|
|
194
|
+
],
|
|
195
|
+
}),
|
|
196
|
+
];
|
|
197
|
+
};
|
|
198
|
+
const inQueue = new Set(
|
|
199
|
+
(loop?.queue ?? [])
|
|
200
|
+
.filter((e) => ['waiting', 'merging'].includes(e.state))
|
|
201
|
+
.map((e) => e.branch),
|
|
202
|
+
);
|
|
203
|
+
// MQ-4: a branch in the merge queue is shown as queued, not as waiting for review.
|
|
204
|
+
const queued = (l) => Boolean(l.branch && inQueue.has(l.branch));
|
|
205
|
+
const needle = search.trim().toLowerCase();
|
|
206
|
+
const shown = state.lanes.filter(
|
|
207
|
+
(l) =>
|
|
208
|
+
(!filter || shownState(l, loop?.queue) === filter) &&
|
|
209
|
+
(!needle ||
|
|
210
|
+
[l.name, l.agent, l.story?.id, l.story?.title, l.branch]
|
|
211
|
+
.filter(Boolean)
|
|
212
|
+
.some((s) => s.toLowerCase().includes(needle))),
|
|
125
213
|
);
|
|
214
|
+
const LIVE = { quiet: 'yellow', stalled: 'red' };
|
|
215
|
+
const rows = shown.map((l) => {
|
|
216
|
+
const lines = [
|
|
217
|
+
Box({
|
|
218
|
+
flexDirection: 'row',
|
|
219
|
+
columnGap: 2,
|
|
220
|
+
children: [
|
|
221
|
+
text(l.name === selected ? '›' : l.active ? '●' : ' ', {
|
|
222
|
+
color: l.name === selected ? 'cyan' : 'green',
|
|
223
|
+
}),
|
|
224
|
+
text(l.name.padEnd(width), {
|
|
225
|
+
bold: true,
|
|
226
|
+
...(l.name === selected ? { color: 'cyan' } : {}),
|
|
227
|
+
}),
|
|
228
|
+
text(
|
|
229
|
+
shownState(l, loop?.queue).padEnd(9),
|
|
230
|
+
shownState(l, loop?.queue) === 'queued'
|
|
231
|
+
? { color: 'cyan' }
|
|
232
|
+
: STATE_COLOR[shownState(l, loop?.queue)]
|
|
233
|
+
? { color: STATE_COLOR[shownState(l, loop?.queue)] }
|
|
234
|
+
: {},
|
|
235
|
+
),
|
|
236
|
+
text(l.agent ?? 'the main session', { dimColor: true }),
|
|
237
|
+
...(live[l.name]
|
|
238
|
+
? [
|
|
239
|
+
text(`${live[l.name].tool} · ${live[l.name].ago}`, {
|
|
240
|
+
wrap: 'truncate-end',
|
|
241
|
+
...(LIVE[live[l.name].level]
|
|
242
|
+
? { color: LIVE[live[l.name].level] }
|
|
243
|
+
: { dimColor: true }),
|
|
244
|
+
}),
|
|
245
|
+
]
|
|
246
|
+
: []),
|
|
247
|
+
...(loop?.laneNotes?.[l.name]
|
|
248
|
+
? [
|
|
249
|
+
text(loop.laneNotes[l.name], {
|
|
250
|
+
color: loop.laneNotes[l.name] === 'stalled' ? 'red' : 'yellow',
|
|
251
|
+
}),
|
|
252
|
+
]
|
|
253
|
+
: []),
|
|
254
|
+
...(usage?.lanes[l.name] ? [text(tokens(usage.lanes[l.name]), { dimColor: true })] : []),
|
|
255
|
+
// JOIN-2: with an adapter that knows, the lane's edits without understanding and cards owed.
|
|
256
|
+
...(combined?.[l.name]
|
|
257
|
+
? [
|
|
258
|
+
text(
|
|
259
|
+
`${combined[l.name].without} edit${combined[l.name].without === 1 ? '' : 's'} without understanding`,
|
|
260
|
+
combined[l.name].without ? { color: 'red' } : { dimColor: true },
|
|
261
|
+
),
|
|
262
|
+
text(
|
|
263
|
+
`${combined[l.name].owed} card${combined[l.name].owed === 1 ? '' : 's'} owed`,
|
|
264
|
+
combined[l.name].owed ? { color: 'yellow' } : { dimColor: true },
|
|
265
|
+
),
|
|
266
|
+
]
|
|
267
|
+
: []),
|
|
268
|
+
],
|
|
269
|
+
}),
|
|
270
|
+
...(l.story
|
|
271
|
+
? [
|
|
272
|
+
text(
|
|
273
|
+
` ${l.story.id} ${l.story.title}${l.branch ? ` · ${l.branch}` : ''}${
|
|
274
|
+
l.state === 'blocked' ? ` · waits on ${l.story.waitingOn.join(', ')}` : ''
|
|
275
|
+
}`,
|
|
276
|
+
{ dimColor: true, wrap: 'truncate-end' },
|
|
277
|
+
),
|
|
278
|
+
]
|
|
279
|
+
: []),
|
|
280
|
+
...bar(l),
|
|
281
|
+
// VIEW-6: the selected lane's detail: the paths it owns and its stories, each with its state.
|
|
282
|
+
...(l.name === selected && detail
|
|
283
|
+
? [
|
|
284
|
+
text(` owns ${detail.owns.length ? detail.owns.join(', ') : 'nothing yet'}`, {
|
|
285
|
+
dimColor: true,
|
|
286
|
+
wrap: 'truncate-end',
|
|
287
|
+
}),
|
|
288
|
+
...(detail.stories.length
|
|
289
|
+
? detail.stories.map((s) =>
|
|
290
|
+
text(
|
|
291
|
+
` ${s.id} ${s.title} · ${s.state}${s.state === 'blocked' && s.waitingOn.length ? ` on ${s.waitingOn.join(', ')}` : ''}`,
|
|
292
|
+
{
|
|
293
|
+
dimColor: s.state === 'done',
|
|
294
|
+
...(s.state === 'blocked' ? { color: 'red' } : {}),
|
|
295
|
+
wrap: 'truncate-end',
|
|
296
|
+
},
|
|
297
|
+
),
|
|
298
|
+
)
|
|
299
|
+
: [text(' no stories in the plan', { dimColor: true })]),
|
|
300
|
+
]
|
|
301
|
+
: []),
|
|
302
|
+
];
|
|
303
|
+
return art
|
|
304
|
+
? Box({
|
|
305
|
+
key: `lane-${l.name}`,
|
|
306
|
+
flexDirection: 'row',
|
|
307
|
+
columnGap: 1,
|
|
308
|
+
children: [character(l), Box({ flexDirection: 'column', children: lines })],
|
|
309
|
+
})
|
|
310
|
+
: Box({ key: `lane-${l.name}`, flexDirection: 'column', children: lines });
|
|
311
|
+
});
|
|
126
312
|
const notes = [];
|
|
127
313
|
if (state.planGaps)
|
|
128
314
|
notes.push(
|
|
@@ -150,12 +336,169 @@ export function lanesPane(state, { Box, Text }) {
|
|
|
150
336
|
rowGap: 1,
|
|
151
337
|
children: [
|
|
152
338
|
...notes,
|
|
153
|
-
Box({
|
|
339
|
+
Box({
|
|
340
|
+
flexDirection: 'column',
|
|
341
|
+
children: rows.length ? rows : [text('No lane matches the filter.', { dimColor: true })],
|
|
342
|
+
}),
|
|
154
343
|
Box({ flexDirection: 'column', children: ready }),
|
|
344
|
+
...reviewsSection(loop, text, Box),
|
|
345
|
+
...loopSection(loop, text, Box),
|
|
155
346
|
],
|
|
156
347
|
});
|
|
157
348
|
}
|
|
158
349
|
|
|
350
|
+
/** How long ago, as a person reads it: now, 40s ago, 12m ago, 2h ago. */
|
|
351
|
+
export function ago(ms) {
|
|
352
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
353
|
+
if (s < 5) return 'now';
|
|
354
|
+
if (s < 60) return `${s}s ago`;
|
|
355
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
356
|
+
return `${Math.floor(s / 3600)}h ago`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** MQ-4: the merge queue in order, then what it merged or sent back lately. */
|
|
360
|
+
export function queueSection(loop, text, Box) {
|
|
361
|
+
const entries = loop?.queue ?? [];
|
|
362
|
+
if (!entries.length) return [];
|
|
363
|
+
const open = entries.filter((e) => ['waiting', 'merging'].includes(e.state));
|
|
364
|
+
const settled = entries
|
|
365
|
+
.filter((e) => !['waiting', 'merging'].includes(e.state))
|
|
366
|
+
.slice(-3)
|
|
367
|
+
.reverse();
|
|
368
|
+
const row = (e, label, style = {}) =>
|
|
369
|
+
Box({
|
|
370
|
+
key: `queue-${e.branch}-${e.state}`,
|
|
371
|
+
flexDirection: 'row',
|
|
372
|
+
columnGap: 2,
|
|
373
|
+
children: [
|
|
374
|
+
text(label.padEnd(3), { dimColor: true }),
|
|
375
|
+
text(e.branch),
|
|
376
|
+
text(e.state, style),
|
|
377
|
+
...(e.reason ? [text(e.reason, { dimColor: true })] : []),
|
|
378
|
+
],
|
|
379
|
+
});
|
|
380
|
+
return [
|
|
381
|
+
Box({
|
|
382
|
+
flexDirection: 'column',
|
|
383
|
+
children: [
|
|
384
|
+
text('Merge queue', { bold: true }),
|
|
385
|
+
...open.map((e, i) => row(e, `${i + 1}.`, { color: 'cyan' })),
|
|
386
|
+
...settled.map((e) => row(e, '·', { color: e.state === 'merged' ? 'green' : 'magenta' })),
|
|
387
|
+
],
|
|
388
|
+
}),
|
|
389
|
+
];
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** REVW-1: each branch's background review, running with its clock, or what it found. */
|
|
393
|
+
export function reviewsSection(loop, text, Box) {
|
|
394
|
+
const reviews = loop?.reviews ?? [];
|
|
395
|
+
if (!reviews.length) return [];
|
|
396
|
+
const said = (r) =>
|
|
397
|
+
r.state === 'running'
|
|
398
|
+
? `running ${ago(loop.now - r.startedAt).replace(' ago', '')}`
|
|
399
|
+
: r.state === 'done'
|
|
400
|
+
? `done · ${r.summary}`
|
|
401
|
+
: r.state === 'skipped'
|
|
402
|
+
? `skipped: ${r.why}`
|
|
403
|
+
: `failed: ${r.error}`;
|
|
404
|
+
return [
|
|
405
|
+
Box({
|
|
406
|
+
flexDirection: 'column',
|
|
407
|
+
children: [
|
|
408
|
+
text('Reviews', { bold: true }),
|
|
409
|
+
...reviews.map((r) =>
|
|
410
|
+
Box({
|
|
411
|
+
key: `review-${r.branch}`,
|
|
412
|
+
flexDirection: 'row',
|
|
413
|
+
columnGap: 2,
|
|
414
|
+
children: [
|
|
415
|
+
text(r.branch),
|
|
416
|
+
text(said(r), {
|
|
417
|
+
color: r.blockers ? 'red' : r.state === 'running' ? 'blue' : undefined,
|
|
418
|
+
dimColor: r.state === 'skipped',
|
|
419
|
+
}),
|
|
420
|
+
],
|
|
421
|
+
}),
|
|
422
|
+
),
|
|
423
|
+
],
|
|
424
|
+
}),
|
|
425
|
+
];
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** The loop's record (spec 05): its state and its latest steps, newest first. */
|
|
429
|
+
export function loopSection(loop, text, Box) {
|
|
430
|
+
if (!loop || (!loop.steps?.length && loop.state !== 'paused')) return [];
|
|
431
|
+
const steps = [...(loop.steps ?? [])].reverse().slice(0, 8);
|
|
432
|
+
return [
|
|
433
|
+
Box({
|
|
434
|
+
flexDirection: 'column',
|
|
435
|
+
children: [
|
|
436
|
+
text(`Loop · ${loop.state === 'paused' ? 'paused' : loop.autonomy}`, { bold: true }),
|
|
437
|
+
...steps.map((s, i) =>
|
|
438
|
+
Box({
|
|
439
|
+
key: `step-${i}`,
|
|
440
|
+
flexDirection: 'row',
|
|
441
|
+
columnGap: 2,
|
|
442
|
+
children: [
|
|
443
|
+
text(ago(loop.now - s.at).padEnd(8), { dimColor: true }),
|
|
444
|
+
text(s.kind.padEnd(8)),
|
|
445
|
+
text(s.target, { wrap: 'truncate-end' }),
|
|
446
|
+
],
|
|
447
|
+
}),
|
|
448
|
+
),
|
|
449
|
+
],
|
|
450
|
+
}),
|
|
451
|
+
];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** USE-2: the plan's 5-hour use as a bar, each story's tokens, and the background agents' share. */
|
|
455
|
+
export function usageSection(usage, plan, text, Box) {
|
|
456
|
+
const lines = [];
|
|
457
|
+
if (plan?.percent != null)
|
|
458
|
+
lines.push(
|
|
459
|
+
Box({
|
|
460
|
+
key: 'usage-plan',
|
|
461
|
+
flexDirection: 'row',
|
|
462
|
+
columnGap: 2,
|
|
463
|
+
children: [
|
|
464
|
+
text('Plan'),
|
|
465
|
+
text(planBar(plan.percent), { color: plan.paused ? 'red' : 'green' }),
|
|
466
|
+
text(`${plan.percent}% of the 5-hour window`, { dimColor: true }),
|
|
467
|
+
...(plan.paused ? [text('background agents paused', { color: 'red' })] : []),
|
|
468
|
+
],
|
|
469
|
+
}),
|
|
470
|
+
);
|
|
471
|
+
if (usage?.total) {
|
|
472
|
+
const flagged = new Map(usage.outliers.map((o) => [o.id, o.ratio]));
|
|
473
|
+
for (const [id, n] of Object.entries(usage.stories).sort((a, b) => b[1] - a[1]))
|
|
474
|
+
lines.push(
|
|
475
|
+
Box({
|
|
476
|
+
key: `usage-${id}`,
|
|
477
|
+
flexDirection: 'row',
|
|
478
|
+
columnGap: 2,
|
|
479
|
+
children: [
|
|
480
|
+
text(id),
|
|
481
|
+
text(tokens(n), { dimColor: true }),
|
|
482
|
+
...(flagged.has(id)
|
|
483
|
+
? [text(`${flagged.get(id)}× the usual`, { color: 'yellow' })]
|
|
484
|
+
: []),
|
|
485
|
+
],
|
|
486
|
+
}),
|
|
487
|
+
);
|
|
488
|
+
if (usage.background)
|
|
489
|
+
lines.push(
|
|
490
|
+
text(
|
|
491
|
+
`Background agents ${tokens(usage.background)} (${Math.round((usage.background / usage.total) * 100)}%)`,
|
|
492
|
+
{ dimColor: true },
|
|
493
|
+
),
|
|
494
|
+
);
|
|
495
|
+
lines.push(text(`This session ${tokens(usage.total)} tokens`, { dimColor: true }));
|
|
496
|
+
}
|
|
497
|
+
return lines.length
|
|
498
|
+
? [Box({ flexDirection: 'column', children: [text('Usage', { bold: true }), ...lines] })]
|
|
499
|
+
: [];
|
|
500
|
+
}
|
|
501
|
+
|
|
159
502
|
// --- the band, and the person's acts -----------------------------------------------------------
|
|
160
503
|
|
|
161
504
|
export const APPROVE_ID = 'code-kit-approve';
|
|
@@ -182,17 +525,94 @@ export function prefilledReason(request) {
|
|
|
182
525
|
|
|
183
526
|
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
184
527
|
|
|
528
|
+
/** What a held call would do, as a person reads it (HOLD-2): "install dayjs", "commit docs/brief.md". */
|
|
529
|
+
export function heldVerb(call) {
|
|
530
|
+
if (call.kind === 'dependency') return `install ${call.names.map(approvalLabel).join(', ')}`;
|
|
531
|
+
if (call.kind === 'kit') return 'change the .claude kit';
|
|
532
|
+
return call.what;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Minutes and seconds left, as 1:53. */
|
|
536
|
+
export function timeLeft(ms) {
|
|
537
|
+
const s = Math.max(0, Math.ceil(ms / 1000));
|
|
538
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const sameAsk = (a, b) =>
|
|
542
|
+
a.names.join(',') === b.names.join(',') && (a.lane ?? null) === (b.lane ?? null);
|
|
543
|
+
|
|
185
544
|
/**
|
|
186
545
|
* The band's lines (BAND-2): one per kind of thing waiting, each with its actions, and none when
|
|
187
546
|
* 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.
|
|
547
|
+
* `notice` is why the person's last act failed, until they dismiss it. `usage` (usageSummary's) flags
|
|
548
|
+
* outliers (USE-3), and `plan` (planOf's) says when background agents are paused (USE-4).
|
|
189
549
|
*/
|
|
190
|
-
export function bandLines({
|
|
550
|
+
export function bandLines({
|
|
551
|
+
state,
|
|
552
|
+
requests,
|
|
553
|
+
stops,
|
|
554
|
+
reviewing = new Set(),
|
|
555
|
+
notice = null,
|
|
556
|
+
usage = null,
|
|
557
|
+
plan = null,
|
|
558
|
+
held = [],
|
|
559
|
+
now = 0,
|
|
560
|
+
loop = null,
|
|
561
|
+
queue = [],
|
|
562
|
+
check = null,
|
|
563
|
+
}) {
|
|
191
564
|
const lines = [];
|
|
192
565
|
if (notice)
|
|
193
566
|
lines.push({ kind: 'notice', text: notice, actions: [{ id: 'dismiss', label: 'Dismiss' }] });
|
|
194
567
|
if (!state || state.kind !== 'ok') return lines;
|
|
195
|
-
|
|
568
|
+
// HOLD-2: calls held for the person, oldest first, with the time left on the oldest.
|
|
569
|
+
if (held.length) {
|
|
570
|
+
const [call] = [...held].sort((a, b) => a.since - b.since);
|
|
571
|
+
const left = timeLeft(call.since + call.minutes * 60000 - now);
|
|
572
|
+
lines.push({
|
|
573
|
+
kind: 'held',
|
|
574
|
+
text: `${held.length > 1 ? `${held.length} calls held: ` : ''}${call.who} wants to ${heldVerb(call)} · ${left}`,
|
|
575
|
+
held: call,
|
|
576
|
+
actions: [
|
|
577
|
+
{ id: 'approveHeld', label: 'Approve' },
|
|
578
|
+
{ id: 'refuseHeld', label: 'Refuse' },
|
|
579
|
+
],
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
// LOOP-3: a lane stalled past its restarts waits for the person.
|
|
583
|
+
for (const f of loop?.flagged ?? [])
|
|
584
|
+
lines.push({
|
|
585
|
+
kind: 'stalled',
|
|
586
|
+
text: `${f.lane} stalled ${f.count} times on ${f.story}`,
|
|
587
|
+
stall: f,
|
|
588
|
+
actions: [
|
|
589
|
+
{ id: 'lanes', label: 'Lanes' },
|
|
590
|
+
{ id: 'resumeStall', label: 'Resume' },
|
|
591
|
+
],
|
|
592
|
+
});
|
|
593
|
+
// LOOP-5: under autonomy propose, each step waits for Go.
|
|
594
|
+
if (loop?.proposal?.first)
|
|
595
|
+
// The first time, the person chooses how hands-off the loop is (until then nothing is taken on its own).
|
|
596
|
+
lines.push({
|
|
597
|
+
kind: 'proposal',
|
|
598
|
+
text: `code-kit can keep the build moving between your prompts. Next: ${loop.proposal.label}.`,
|
|
599
|
+
actions: [
|
|
600
|
+
{ id: 'keepGoing', label: 'Keep going' },
|
|
601
|
+
{ id: 'askEach', label: 'Ask each time' },
|
|
602
|
+
{ id: 'loopOff', label: 'Off' },
|
|
603
|
+
],
|
|
604
|
+
});
|
|
605
|
+
else if (loop?.proposal)
|
|
606
|
+
lines.push({
|
|
607
|
+
kind: 'proposal',
|
|
608
|
+
// LOOP-8: a step the lead was asked for and didn't take waits for the person to ask again.
|
|
609
|
+
text: loop.proposal.repeated
|
|
610
|
+
? `${loop.proposal.label}: still next after ${loop.proposal.repeated} prompts. Ask the lead again?`
|
|
611
|
+
: `${loop.proposal.label}?`,
|
|
612
|
+
actions: [{ id: 'go', label: 'Go' }],
|
|
613
|
+
});
|
|
614
|
+
// A held call's request is asked on its own line above, not again here.
|
|
615
|
+
const open = (requests?.open ?? []).filter((r) => !held.some((h) => sameAsk(h, r)));
|
|
196
616
|
if (open.length)
|
|
197
617
|
lines.push({
|
|
198
618
|
kind: 'approvals',
|
|
@@ -224,6 +644,50 @@ export function bandLines({ state, requests, stops, reviewing = new Set(), notic
|
|
|
224
644
|
text: `Finish check failing: ${stops[0].title}${stops.length > 1 ? ` (and ${stops.length - 1} more)` : ''}`,
|
|
225
645
|
actions: [{ id: 'lanes', label: 'Lanes' }],
|
|
226
646
|
});
|
|
647
|
+
if (usage?.outliers.length) {
|
|
648
|
+
const [top] = usage.outliers;
|
|
649
|
+
const more = usage.outliers.length - 1;
|
|
650
|
+
lines.push({
|
|
651
|
+
kind: 'usage',
|
|
652
|
+
text: `${top.id} has used ${top.ratio}× the usual${more ? ` (and ${more} more)` : ''}`,
|
|
653
|
+
actions: [{ id: 'lanes', label: 'Lanes' }],
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
if (plan?.paused)
|
|
657
|
+
lines.push({
|
|
658
|
+
kind: 'paused',
|
|
659
|
+
text: `background agents paused: plan at ${plan.percent}%`,
|
|
660
|
+
actions: [],
|
|
661
|
+
});
|
|
662
|
+
// MQ-2: where the lead doesn't merge, the head of the queue waits for the person's Merge.
|
|
663
|
+
const next = queue.find((e) => ['waiting', 'merging'].includes(e.state));
|
|
664
|
+
const leadMerges =
|
|
665
|
+
check?.delegatesMerge && (check?.harness?.autonomy ?? 'autonomous') === 'autonomous';
|
|
666
|
+
if (next && !leadMerges)
|
|
667
|
+
lines.push({
|
|
668
|
+
kind: 'queue',
|
|
669
|
+
// The first merge brings code-kit's rules onto the base, so it's the person's even under autonomy.
|
|
670
|
+
text: check?.firstMerge
|
|
671
|
+
? `${next.branch} is next to merge: the first merge is yours, then the lead merges`
|
|
672
|
+
: `${next.branch} is next to merge`,
|
|
673
|
+
branch: next.branch,
|
|
674
|
+
actions: [{ id: 'mergeQueue', label: 'Merge' }],
|
|
675
|
+
});
|
|
676
|
+
// LOOP-7: the milestone is done; LOOP-6: the loop's own state, with Pause or Resume.
|
|
677
|
+
if (loop?.state === 'done')
|
|
678
|
+
lines.push({
|
|
679
|
+
kind: 'done',
|
|
680
|
+
text: `Milestone done: ${plural(loop.doneCount, 'story', 'stories')} merged`,
|
|
681
|
+
actions: [{ id: 'dismissDone', label: 'Dismiss' }],
|
|
682
|
+
});
|
|
683
|
+
else if (loop?.state === 'paused')
|
|
684
|
+
lines.push({ kind: 'loop', text: 'loop paused', actions: [{ id: 'resume', label: 'Resume' }] });
|
|
685
|
+
else if (loop?.state === 'running' && loop.started)
|
|
686
|
+
lines.push({
|
|
687
|
+
kind: 'loop',
|
|
688
|
+
text: `loop on${loop.last ? ` · last: ${loop.last}` : ''}`,
|
|
689
|
+
actions: [{ id: 'pause', label: 'Pause' }],
|
|
690
|
+
});
|
|
227
691
|
// BAND-4: a digit for every action, in the order they're drawn.
|
|
228
692
|
let digit = 0;
|
|
229
693
|
for (const line of lines)
|
|
@@ -233,7 +697,20 @@ export function bandLines({ state, requests, stops, reviewing = new Set(), notic
|
|
|
233
697
|
|
|
234
698
|
/** The band, drawn with the elements `$.ui.resolve(e)` returned; `onAction(id, line)` acts. */
|
|
235
699
|
export function band(lines, { Box, Text, Button }, onAction) {
|
|
236
|
-
const COLOR = {
|
|
700
|
+
const COLOR = {
|
|
701
|
+
notice: 'red',
|
|
702
|
+
held: 'yellow',
|
|
703
|
+
queue: 'blue',
|
|
704
|
+
stalled: 'red',
|
|
705
|
+
proposal: 'cyan',
|
|
706
|
+
loop: 'green',
|
|
707
|
+
done: 'green',
|
|
708
|
+
approvals: 'yellow',
|
|
709
|
+
review: 'blue',
|
|
710
|
+
finish: 'red',
|
|
711
|
+
usage: 'yellow',
|
|
712
|
+
paused: 'yellow',
|
|
713
|
+
};
|
|
237
714
|
return Box({
|
|
238
715
|
flexDirection: 'column',
|
|
239
716
|
children: lines.map((line) =>
|
|
@@ -351,6 +828,25 @@ export function refusalCard(raw) {
|
|
|
351
828
|
request: request(`write ${m[1]}`),
|
|
352
829
|
raw: text,
|
|
353
830
|
};
|
|
831
|
+
m = first.match(/^(\S+) is (.+); committing it needs a person's approval in force\./);
|
|
832
|
+
if (m)
|
|
833
|
+
return {
|
|
834
|
+
kind: 'approval',
|
|
835
|
+
title: `Commit refused: ${m[1]}`,
|
|
836
|
+
why: `It's ${m[2]}.`,
|
|
837
|
+
todo: 'A person approves it, here or with the `!` command, for 60 minutes.',
|
|
838
|
+
request: request(`commit ${m[1]}`),
|
|
839
|
+
raw: text,
|
|
840
|
+
};
|
|
841
|
+
if (/^the \.claude kit is changed only by the lead or with a person's approval\./.test(first))
|
|
842
|
+
return {
|
|
843
|
+
kind: 'kit',
|
|
844
|
+
title: 'Kit edit refused',
|
|
845
|
+
why: "The .claude kit is changed only by the lead or with a person's approval.",
|
|
846
|
+
todo: 'A person approves it, here or with the `!` command, for 60 minutes.',
|
|
847
|
+
request: request('change the .claude kit'),
|
|
848
|
+
raw: text,
|
|
849
|
+
};
|
|
354
850
|
m = first.match(/^a new dependency \((.+?)\) needs a person's approval\./);
|
|
355
851
|
if (m)
|
|
356
852
|
return {
|
|
@@ -425,3 +921,234 @@ export function approvalsText(requests) {
|
|
|
425
921
|
];
|
|
426
922
|
return lines.join('\n');
|
|
427
923
|
}
|
|
924
|
+
|
|
925
|
+
// --- the harness settings (SET-2) --------------------------------------------------------------
|
|
926
|
+
|
|
927
|
+
export const SETTINGS_ID = 'code-kit-settings';
|
|
928
|
+
const CHOICES = {
|
|
929
|
+
autonomy: ['autonomous', 'propose', 'off'],
|
|
930
|
+
'agents.reviewer.model': ['', 'haiku', 'sonnet', 'opus'],
|
|
931
|
+
};
|
|
932
|
+
const shown = (v) =>
|
|
933
|
+
v === null || v === '' ? "the session's" : v === true ? 'on' : v === false ? 'off' : String(v);
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* The Settings view: each setting from `code-kit settings --json` with a control, and, once one is
|
|
937
|
+
* changed, a confirmation asking the person's reason before anything is written.
|
|
938
|
+
*/
|
|
939
|
+
export function settingsView(rows, pending, { Box, Text, Select, Input, Button }, handlers) {
|
|
940
|
+
const control = (r) => {
|
|
941
|
+
const listed = CHOICES[r.key] ?? (typeof r.default === 'boolean' ? [true, false] : null);
|
|
942
|
+
// A value the list doesn't name (a model given by its id) is still shown, as the one chosen.
|
|
943
|
+
const choices =
|
|
944
|
+
listed && !listed.map(String).includes(String(r.value ?? '')) ? [...listed, r.value] : listed;
|
|
945
|
+
if (choices)
|
|
946
|
+
return Select({
|
|
947
|
+
key: `set-${r.key}`,
|
|
948
|
+
label: '',
|
|
949
|
+
value: String(r.value ?? ''),
|
|
950
|
+
options: choices.map((c) => ({ value: String(c), label: shown(c) })),
|
|
951
|
+
onSelect: (v) => handlers.onChoose(r.key, v === '' ? 'null' : v),
|
|
952
|
+
});
|
|
953
|
+
return Input({
|
|
954
|
+
key: `set-${r.key}`,
|
|
955
|
+
label: '',
|
|
956
|
+
value: String(r.value),
|
|
957
|
+
submitLabel: 'Set',
|
|
958
|
+
onSubmit: (v) => handlers.onChoose(r.key, v.trim()),
|
|
959
|
+
});
|
|
960
|
+
};
|
|
961
|
+
const rowsView = rows.map((r) =>
|
|
962
|
+
Box({
|
|
963
|
+
key: `setting-${r.key}`,
|
|
964
|
+
flexDirection: 'column',
|
|
965
|
+
children: [
|
|
966
|
+
Box({
|
|
967
|
+
flexDirection: 'row',
|
|
968
|
+
columnGap: 2,
|
|
969
|
+
children: [
|
|
970
|
+
Box({ width: 28, children: [Text({ bold: true, children: [r.key] })] }),
|
|
971
|
+
control(r),
|
|
972
|
+
...(r.value !== r.default
|
|
973
|
+
? [Text({ dimColor: true, children: [`default ${shown(r.default)}`] })]
|
|
974
|
+
: []),
|
|
975
|
+
...(r.key === 'autonomy' && r.set === false
|
|
976
|
+
? [
|
|
977
|
+
Text({
|
|
978
|
+
color: 'yellow',
|
|
979
|
+
children: ['not chosen yet: the loop asks before its first step'],
|
|
980
|
+
}),
|
|
981
|
+
]
|
|
982
|
+
: []),
|
|
983
|
+
],
|
|
984
|
+
}),
|
|
985
|
+
Text({ dimColor: true, children: [` ${r.about}`] }),
|
|
986
|
+
],
|
|
987
|
+
}),
|
|
988
|
+
);
|
|
989
|
+
const confirm = pending
|
|
990
|
+
? [
|
|
991
|
+
Box({
|
|
992
|
+
key: 'settings-confirm',
|
|
993
|
+
flexDirection: 'column',
|
|
994
|
+
borderStyle: 'round',
|
|
995
|
+
paddingX: 1,
|
|
996
|
+
children: [
|
|
997
|
+
Text({
|
|
998
|
+
bold: true,
|
|
999
|
+
children: [
|
|
1000
|
+
`Set ${pending.key} to ${shown(pending.value === 'null' ? null : pending.value)}?`,
|
|
1001
|
+
],
|
|
1002
|
+
}),
|
|
1003
|
+
Text({
|
|
1004
|
+
dimColor: true,
|
|
1005
|
+
children: [
|
|
1006
|
+
'It goes into .claude/code-kit.json, and the approval log keeps your reason.',
|
|
1007
|
+
],
|
|
1008
|
+
}),
|
|
1009
|
+
Input({
|
|
1010
|
+
key: 'settings-reason',
|
|
1011
|
+
label: 'Reason',
|
|
1012
|
+
value: pending.reason,
|
|
1013
|
+
submitLabel: 'Change it',
|
|
1014
|
+
autoFocus: true,
|
|
1015
|
+
onInput: handlers.onReason,
|
|
1016
|
+
onSubmit: handlers.onConfirm,
|
|
1017
|
+
}),
|
|
1018
|
+
...(pending.error ? [Text({ color: 'red', children: [pending.error] })] : []),
|
|
1019
|
+
Box({
|
|
1020
|
+
flexDirection: 'row',
|
|
1021
|
+
columnGap: 2,
|
|
1022
|
+
children: [
|
|
1023
|
+
Button({
|
|
1024
|
+
key: 'settings-ok',
|
|
1025
|
+
label: 'Change it',
|
|
1026
|
+
onPress: () => handlers.onConfirm(pending.reason),
|
|
1027
|
+
}),
|
|
1028
|
+
Button({ key: 'settings-cancel', label: 'Cancel', onPress: handlers.onCancel }),
|
|
1029
|
+
],
|
|
1030
|
+
}),
|
|
1031
|
+
],
|
|
1032
|
+
}),
|
|
1033
|
+
]
|
|
1034
|
+
: [];
|
|
1035
|
+
return Box({
|
|
1036
|
+
flexDirection: 'column',
|
|
1037
|
+
rowGap: 1,
|
|
1038
|
+
children: [
|
|
1039
|
+
Text({ bold: true, color: 'cyan', children: ['HARNESS SETTINGS'] }),
|
|
1040
|
+
...confirm,
|
|
1041
|
+
Box({ flexDirection: 'column', children: rowsView }),
|
|
1042
|
+
Text({
|
|
1043
|
+
dimColor: true,
|
|
1044
|
+
children: ['Lanes, paths, layers and protected paths change through /code-kit:init.'],
|
|
1045
|
+
}),
|
|
1046
|
+
],
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// --- usage per lane (USE-1 to USE-4) -----------------------------------------------------------
|
|
1051
|
+
|
|
1052
|
+
const STORY_ID = /\bST-\d+\b/;
|
|
1053
|
+
|
|
1054
|
+
/** A model request's tokens: input (cache writes included), output and cache reads. */
|
|
1055
|
+
export function tokensOf(usage) {
|
|
1056
|
+
return {
|
|
1057
|
+
input: (usage?.input_tokens ?? 0) + (usage?.cache_creation_input_tokens ?? 0),
|
|
1058
|
+
output: usage?.output_tokens ?? 0,
|
|
1059
|
+
cache: usage?.cache_read_input_tokens ?? 0,
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Whom a model request is for (USE-1): the lead when no agent made it; a lane's agent by its type,
|
|
1065
|
+
* on the story its brief names or else the story its lane is on; any other agent is background.
|
|
1066
|
+
* `agent` is the `$.agent.list()` entry, or null when the list no longer has it.
|
|
1067
|
+
*/
|
|
1068
|
+
export function attribution(agentId, agent, check, state) {
|
|
1069
|
+
const laneStory = (name) => state?.lanes?.find((l) => l.name === name)?.story?.id ?? null;
|
|
1070
|
+
if (!agentId) return { key: 'lead', lane: LEAD, story: laneStory(LEAD), background: false };
|
|
1071
|
+
const lane = Object.entries(check?.lanes ?? {}).find(([, l]) => l.agent === agent?.type)?.[0];
|
|
1072
|
+
const named = agent?.description?.match(STORY_ID)?.[0] ?? null;
|
|
1073
|
+
if (!lane) return { key: agentId, lane: null, story: named, background: true };
|
|
1074
|
+
return { key: agentId, lane, story: named ?? laneStory(lane), background: false };
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/** The ledger with one request added: entries by agent and story, their tokens summed. */
|
|
1078
|
+
export function withUsage(ledger, who, usage) {
|
|
1079
|
+
const id = `${who.key}|${who.story ?? ''}`;
|
|
1080
|
+
const t = tokensOf(usage);
|
|
1081
|
+
const was = ledger[id] ?? { ...who, input: 0, output: 0, cache: 0 };
|
|
1082
|
+
return {
|
|
1083
|
+
...ledger,
|
|
1084
|
+
[id]: {
|
|
1085
|
+
...was,
|
|
1086
|
+
input: was.input + t.input,
|
|
1087
|
+
output: was.output + t.output,
|
|
1088
|
+
cache: was.cache + t.cache,
|
|
1089
|
+
},
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const total = (e) => e.input + e.output + e.cache;
|
|
1094
|
+
const median = (xs) => {
|
|
1095
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
1096
|
+
const m = Math.floor(s.length / 2);
|
|
1097
|
+
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1100
|
+
/** The stories finished: done, or their lane's branch waiting for review. */
|
|
1101
|
+
export function finishedStories(state) {
|
|
1102
|
+
if (state?.kind !== 'ok') return new Set();
|
|
1103
|
+
return new Set([
|
|
1104
|
+
...(state.stories ?? []).filter((s) => s.state === 'done').map((s) => s.id),
|
|
1105
|
+
...state.lanes.filter((l) => l.state === 'in review').map((l) => l.story.id),
|
|
1106
|
+
]);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* The session's usage rolled up (USE-1, USE-2): tokens per lane, per story and for the background
|
|
1111
|
+
* agents, and the outliers (USE-3), stories past three times the median of the finished ones once
|
|
1112
|
+
* at least three have finished.
|
|
1113
|
+
*/
|
|
1114
|
+
export function usageSummary(ledger, finished = new Set()) {
|
|
1115
|
+
const lanes = {};
|
|
1116
|
+
const stories = {};
|
|
1117
|
+
let background = 0;
|
|
1118
|
+
let all = 0;
|
|
1119
|
+
for (const e of Object.values(ledger ?? {})) {
|
|
1120
|
+
const n = total(e);
|
|
1121
|
+
all += n;
|
|
1122
|
+
if (e.background) background += n;
|
|
1123
|
+
else lanes[e.lane] = (lanes[e.lane] ?? 0) + n;
|
|
1124
|
+
if (e.story) stories[e.story] = (stories[e.story] ?? 0) + n;
|
|
1125
|
+
}
|
|
1126
|
+
const done = Object.entries(stories).filter(([id]) => finished.has(id));
|
|
1127
|
+
const usual = done.length >= 3 ? median(done.map(([, n]) => n)) : null;
|
|
1128
|
+
const outliers = usual
|
|
1129
|
+
? Object.entries(stories)
|
|
1130
|
+
.filter(([, n]) => n > 3 * usual)
|
|
1131
|
+
.map(([id, n]) => ({ id, ratio: Math.round((n / usual) * 10) / 10 }))
|
|
1132
|
+
.sort((a, b) => b.ratio - a.ratio)
|
|
1133
|
+
: [];
|
|
1134
|
+
return { lanes, stories, background, total: all, outliers };
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** The plan's 5-hour use from the session's limits (null off a subscription), and the pause (USE-4). */
|
|
1138
|
+
export function planOf(rateLimits, pauseAtPercent = 80) {
|
|
1139
|
+
const percent = (rateLimits ?? []).find((r) => r.kind === 'five_hour')?.percentUsed ?? null;
|
|
1140
|
+
return { percent, paused: percent !== null && percent > pauseAtPercent };
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/** Tokens as a person reads them: 950, 12k, 1.4M. */
|
|
1144
|
+
export function tokens(n) {
|
|
1145
|
+
if (n < 1000) return String(n);
|
|
1146
|
+
if (n < 1e6) return `${Math.round(n / 1000)}k`;
|
|
1147
|
+
return `${(n / 1e6).toFixed(1)}M`;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/** The plan's use as a bar of ten cells. */
|
|
1151
|
+
export function planBar(percent) {
|
|
1152
|
+
const full = Math.max(0, Math.min(10, Math.round(percent / 10)));
|
|
1153
|
+
return `${'█'.repeat(full)}${'░'.repeat(10 - full)}`;
|
|
1154
|
+
}
|