Claude Code skills for clean code, engineering workflows and project management, with the engineering workflow, its playbooks and engineering principles.

Claude Code skills for clean code, engineering workflows and project management, packaged as a plugin.
Install the plugin from this repository's marketplace, in one step on Claude Code 2.1.275 or later.
/plugin install forge --marketplace renefatuaki/skills
Install the dependencies, which also wires the git hooks.
pnpm install
| Command | What it does |
|---|---|
pnpm run format | formats every file with oxfmt |
pnpm run format:check | reports unformatted files |
pnpm run lint | runs oxlint |
pnpm run lint:fix | runs oxlint and applies its fixes |
pnpm run typecheck | checks the .mts files with tsc |
pnpm test | runs the tests of the comment rules, the documentation rules, the diagram rules, the session hook, the Codex model script, the turn hook and the app token script |
pnpm run check-upstream | lists what changed upstream since the base commit of every file in UPSTREAM.md, with gh logged in |
.changeset/.hooks/ run when a session starts and while a turn runs. hooks/README.md shows how they work together.The clean-code skill carries a formatter and linter configuration per language under skills/clean-code/clean-code/formatting/. Which rule each tool covers, and where the agent fills the gap, is listed in coverage.md.
The engineering skill works every development task through the playbook that matches it. It reads the task, opens that playbook and follows its steps, from a question about the code to a GitHub issue worked to a pull request ready for review.
| Entry point | What it does |
|---|---|
/engineering | turns engineering on for the rest of the session, so every task runs the playbook that matches it |
/engineering #<issue> | works the GitHub issue through the Ticket playbook, from the board to a pull request ready for review |
The today-issues skill starts each ticket of its plan with /engineering <issue-url>, which takes the same route.
| Playbook | Runs for |
|---|---|
| Ticket | a GitHub issue, worked in its own worktree to a pull request ready for review |
| Feature | new or changed behaviour |
| Bug fix | a reported defect to reproduce, find the root cause of and fix |
| Refactoring | a change to the structure that keeps the behaviour |
| Investigation | a read-only question about the code |
| Perf issue | a measured slowness, improved against a baseline |
| Hillclimb | one metric, improved attempt by attempt until it reaches a target |
| Runtime forensics | a live symptom such as a leak, a CPU spin or a glitch, diagnosed from instrumentation without a fix |
| Trace forensics | a captured profile, trace, spindump or heap snapshot, diagnosed after the fact without a fix |
| Prototype | a throwaway sketch that settles a design decision |
| Visual parity | a UI that must match a baseline pixel for pixel, checked by image diff |
| Authoring a skill | a SKILL.md written or edited |
| Eval | a skill or prompt change, tested on blinded agents before it is promoted |
| Autonomous run | a long task driven to a checkable exit condition without stopping |
| Session pickup | the unfinished work of an earlier session, resumed from its transcript or branch |
| Worktree cleanup | disk space, reclaimed from merged worktrees and stale simulators |
| Opening a PR | the end of every other playbook, the diff cleaned, committed and opened ready for review |
A large or cross-cutting task, or one that no playbook fits, goes to the figure-it-out skill.
The skills of the plugin live in one folder per category.
| Category | Skills | What they do |
|---|---|---|
workflow | engineering, how, why, architect, arena, interrogate, swarm, blast-radius, figure-it-out, tdd, show-me-your-work | the frame and the tools a playbook runs |
principles | 24 principles, such as prove-it-works | the rules a playbook names for each decision |
verification | control-ui, control-cli, verify-this, create-verification-skill, maintain-verification-skill, benchmark-checklist | drive the running application and check a claim |
meta | reflect, correct, recall, automate-me, teach, bro, setup-models | learn from past sessions, explain, and configure the models |
docs | diagrams, project-docs, unslop, technical-writing | write documentation and prose |
clean-code | clean-code, code-comments, typescript-best-practices | write readable code |
git | commit, tidy-history | write commits and tidy the history of a branch |
github | issue, project, pull-request, pull-request-review, review-queue, next-steps, templates, today-issues, loop-on-ci | plan and ship work on GitHub |
tooling | package-manager | run the package manager of the repository |
A skill with disable-model-invocation: true in its frontmatter starts only through its slash command. The engineering skill and its playbooks read such a skill from its SKILL.md instead.
Run /setup-models once to pick the model and the reasoning effort of each role a skill of this plugin spawns. It writes ~/.claude/rules/models.md, and without that file every role runs on its default.
The git/commit and github/pull-request skills bring their own rules for commits and pull requests. Claude Code also loads built-in commit and pull request instructions into its context, and the two can conflict. If Claude does not follow the skills, turn off the built-in instructions with includeGitInstructions in ~/.claude/settings.json or in the project's .claude/settings.json:
{
"includeGitInstructions": false
}hooks/progress/index.mts 369 lines1// Turns the main session's todo calls, Codex calls, prompts and usage measurements into events of the run, reads the Codex usage when the session starts, on each prompt and after each Codex call, reads the Claude usage when the session starts, and draws the run above the prompt.
2// https://code.claude.com/docs/en/plugins/mods/events.md
3
4import type {
5 BuiltinToolInputs,
6 ElementTable,
7 EngineInterface,
8 PromptOrigin,
9 Register,
10 RenderElement,
11 SessionRateLimit,
12 TextProps,
13 ToolCallResult,
14} from 'claude-code';
15
16import { bandLine, bandOf, INITIAL_RUN, parseUsage, step } from './run.mts';
17import type {
18 Band,
19 Budget,
20 CodexUsage,
21 Event,
22 Progress,
23 Run,
24 Tone,
25} from './run.mts';
26
27/** The run of the session, written only by `dispatch`. */
28let run: Run = INITIAL_RUN;
29
30/** Outlasts the usage script's own 30 s timeout, so the script reports its failure before the call is cut. */
31const READ_TIMEOUT_MS = 40_000;
32
33/** Names the origins of a prompt the person sent, so a task notification never dismisses a finished list. */
34const PERSON_ORIGINS: ReadonlySet<PromptOrigin['kind']> = new Set([
35 'composer',
36 'bridge',
37 'sdk',
38 'slack-ping',
39]);
40
41const TONES: Record<Tone, TextProps> = {
42 plain: {},
43 quiet: { dimColor: true },
44 alarm: { color: 'error' },
45};
46
47/** Colors the windows of a usage caption, which stay in the text color unless below the Codex minimum. */
48const WINDOW_TONES: Record<CodexUsage['tone'], TextProps> = {
49 quiet: {},
50 alarm: { color: 'error' },
51};
52
53/** Cells the bar of the done share takes beside the count. */
54const BAR_CELLS = 8;
55
56export const register: Register = on => {
57 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
58 const ran = await next(e);
59
60 if (e.agentId === undefined && succeeded(ran)) {
61 const todos = e.todos.map(({ content, status }) => ({
62 id: null,
63 title: content,
64 status,
65 }));
66
67 dispatch($, { kind: 'todosWritten', todos });
68 }
69
70 return ran;
71 }).catch(($, e, next) => next(e));
72
73 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
74 const ran = await next(e);
75
76 if (e.agentId === undefined && succeeded(ran)) {
77 const { id, subject } = ran.result.task;
78 const task = { id, title: subject, status: 'pending' as const };
79
80 dispatch($, { kind: 'taskCreated', task });
81 }
82
83 return ran;
84 }).catch(($, e, next) => next(e));
85
86 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
87 const ran = await next(e);
88
89 if (e.agentId === undefined && succeeded(ran) && ran.result.success) {
90 dispatch($, taskEvent(e, ran.result.updatedFields));
91 }
92
93 return ran;
94 }).catch(($, e, next) => next(e));
95
96 on(
97 'tool.call',
98 { tool: 'Agent', subagent_type: 'codex:codex-rescue' },
99 async ($, e, next) => {
100 const ran = await next(e);
101
102 if (succeeded(ran)) {
103 void readUsage($);
104 }
105
106 return ran;
107 },
108 ).catch(($, e, next) => next(e));
109
110 on('prompt.submit', ($, e, next) => {
111 if (e.isMeta === true) {
112 return next(e);
113 }
114
115 if (PERSON_ORIGINS.has(e.origin.kind)) {
116 dispatch($, { kind: 'personPrompted' });
117 }
118
119 void readUsage($);
120
121 return next(e);
122 }).catch(($, e, next) => next(e));
123
124 on('session.start', ($, e, next) => {
125 void readUsage($);
126 void readSessionUsage($);
127
128 return next(e);
129 });
130
131 on('session.measure', ($, e, next) => {
132 dispatch($, { kind: 'claudeMeasured', left: sessionLeft(e.rateLimits) });
133
134 return next(e);
135 });
136
137 on('session.end', ($, e, next) => {
138 if (e.reason === 'clear') {
139 dispatch($, { kind: 'cleared' });
140 }
141
142 return next(e);
143 });
144
145 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
146 const band = bandOf(run);
147
148 if (band === null || e.props.hasSurvey) {
149 return next(e);
150 }
151
152 // Every other surface has the Box and Text the graphical tree draws with, mobile included.
153 return e.surface === 'terminal'
154 ? terminalBand($.ui.resolve(e), band, e.props.bodyColumns)
155 : graphicalBand($.ui.resolve(e), band);
156 });
157};
158
159/** Draws the band as one line of characters cut to the columns, the Claude usage at the left edge and the Codex usage padded to the right edge. */
160function terminalBand(
161 { Text }: Pick<ElementTable, 'Text'>,
162 band: Band,
163 columns: number,
164): RenderElement {
165 const children = bandLine(band, columns).map(({ text, tone }) =>
166 Text({ ...TONES[tone], children: [text] }),
167 );
168
169 return Text({ wrap: 'truncate-end', children });
170}
171
172/** Draws the band as a row of the Claude usage as a caption at the left end, the progress and the Codex usage as a caption at the right end. */
173function graphicalBand(
174 { Box, Text }: Pick<ElementTable, 'Box' | 'Text'>,
175 band: Band,
176): RenderElement {
177 const claude =
178 band.claude === null
179 ? []
180 : [
181 usageCaption({ Box, Text }, 'Claude', {
182 windows: [band.claude.window],
183 tone: 'quiet',
184 }),
185 ];
186
187 const codex =
188 band.codex === null
189 ? []
190 : [usageCaption({ Box, Text }, 'Codex', band.codex)];
191
192 if (band.progress === null) {
193 return Box({
194 flexDirection: 'row',
195 alignItems: 'center',
196 justifyContent: band.claude === null ? 'flex-end' : 'space-between',
197 children: [...claude, ...codex],
198 });
199 }
200
201 return Box({
202 flexDirection: 'row',
203 alignItems: 'center',
204 gap: 'sm',
205 children: [
206 ...claude,
207 ...progressParts({ Box, Text }, band.progress),
208 ...codex,
209 ],
210 });
211}
212
213/** Draws the count with a bar of the done share and the title the surface truncates by its own width. */
214function progressParts(
215 { Box, Text }: Pick<ElementTable, 'Box' | 'Text'>,
216 { count, filled, title }: Progress,
217): RenderElement[] {
218 const progress = Box({
219 flexDirection: 'row',
220 alignItems: 'center',
221 gap: 'xs',
222 flexShrink: 0,
223 children: [
224 Text({ tabular: true, weight: 'medium', children: [count] }),
225 Box({
226 width: BAR_CELLS,
227 height: 1,
228 backgroundColor: 'subtle',
229 children: [
230 Box({
231 width: filled,
232 height: 1,
233 backgroundColor: title === null ? 'success' : 'claude',
234 }),
235 ],
236 }),
237 ],
238 });
239
240 // A flex item does not shrink below its content without a minimum width, so the title would push the Codex usage out instead of truncating.
241 // https://developer.mozilla.org/en-US/docs/Web/CSS/min-width#auto
242 const titleBox = Box({
243 flexGrow: 1,
244 minWidth: 0,
245 children: [
246 title === null
247 ? Text({ dimColor: true, children: ['done'] })
248 : Text({ wrap: 'truncate-end', children: [title] }),
249 ],
250 });
251
252 return [progress, titleBox];
253}
254
255/** Draws the label such as `Codex` and each window as small text, the windows and `below minimum` in the error color in the alarm tone. */
256function usageCaption(
257 { Box, Text }: Pick<ElementTable, 'Box' | 'Text'>,
258 label: string,
259 { windows, tone }: CodexUsage,
260): RenderElement {
261 const separator = Text({ size: 'caption', dimColor: true, children: ['·'] });
262 const parts = windows.flatMap((window, index) => [
263 ...(index === 0 ? [] : [separator]),
264 Text({
265 size: 'caption',
266 tabular: true,
267 ...WINDOW_TONES[tone],
268 children: [window],
269 }),
270 ]);
271
272 const alarm =
273 tone === 'alarm'
274 ? [
275 Text({
276 size: 'caption',
277 ...WINDOW_TONES.alarm,
278 children: ['below minimum'],
279 }),
280 ]
281 : [];
282
283 return Box({
284 flexDirection: 'row',
285 alignItems: 'center',
286 gap: 'xs',
287 flexShrink: 0,
288 children: [
289 Text({ size: 'caption', dimColor: true, children: [label] }),
290 ...parts,
291 ...alarm,
292 ],
293 });
294}
295
296/** Applies an event to the run, the one writer of the run, and draws the band again. */
297function dispatch($: EngineInterface, event: Event): void {
298 run = step(run, event);
299 $.ui.invalidate('ui.render');
300}
301
302/** Reads the usage outside any hook's time and applies the answer as one more event. */
303async function readUsage($: EngineInterface): Promise<void> {
304 dispatch($, { kind: 'codexRead', budget: await runUsageScript($) });
305}
306
307/** Reads the Claude usage outside the session start hook's time and applies it as one more event, with no reading when the read is refused. */
308async function readSessionUsage($: EngineInterface): Promise<void> {
309 const rateLimits = await $.session.usage().then(
310 usage => usage.rateLimits,
311 () => [],
312 );
313
314 dispatch($, { kind: 'claudeMeasured', left: sessionLeft(rateLimits) });
315}
316
317/** Parses the rate-limit windows into the whole percent left of the current session's 5-hour window, rounded down and held to 0 to 100, null with no reading of it. */
318function sessionLeft(rateLimits: readonly SessionRateLimit[]): number | null {
319 const session = rateLimits.find(limit => limit.kind === 'five_hour');
320
321 if (session === undefined) {
322 return null;
323 }
324
325 return Math.min(100, Math.max(0, Math.floor(100 - session.percentUsed)));
326}
327
328/** Asks `codex-model.mts --usage` for the usage left, null when Node, the Codex CLI or the sign-in is missing or the read fails. */
329async function runUsageScript($: EngineInterface): Promise<Budget | null> {
330 const script = `${$.plugin.root}/skills/workflow/engineering/scripts/codex-model.mts`;
331
332 try {
333 const { exitCode, stdout } = await $.process.run(
334 ['node', script, '--usage'],
335 { timeoutMs: READ_TIMEOUT_MS },
336 );
337
338 return exitCode === 0 ? parseUsage(stdout) : null;
339 } catch {
340 return null;
341 }
342}
343
344/** Tells a tool's result from a refusal and from an error the tool reported. */
345function succeeded<T extends string>(
346 ran: ToolCallResult<T>,
347): ran is Extract<ToolCallResult<T>, { result: unknown; isError?: undefined }> {
348 return ran.deny === undefined && ran.isError !== true;
349}
350
351/** Parses a TaskUpdate call that the tool accepted into the event of the run, taking only the fields its result says it applied. */
352function taskEvent(
353 input: BuiltinToolInputs['TaskUpdate'],
354 updated: readonly string[],
355): Event {
356 const status = updated.includes('status') ? input.status : undefined;
357
358 if (status === 'deleted') {
359 return { kind: 'taskDeleted', id: input.taskId };
360 }
361
362 return {
363 kind: 'taskEdited',
364 id: input.taskId,
365 title: updated.includes('subject') ? input.subject : undefined,
366 status,
367 };
368}
369hooks/progress/run.mts 385 lines1// Folds what happens in a session into the run of the progress band, reads what the band shows and lays out the terminal's line, with no Node and no engine.
2// https://code.claude.com/docs/en/plugins/mods/events.md
3
4/** Where an item of the list stands, as TodoWrite and the Task tools spell it. */
5type ItemStatus = 'pending' | 'in_progress' | 'completed';
6
7/** A todo or a task of the main session's list, with the id TaskCreate's result gave a task and null for a TodoWrite todo. */
8export type Item = {
9 readonly id: string | null;
10 readonly title: string;
11 readonly status: ItemStatus;
12};
13
14/** The Codex usage left of the 5-hour and the weekly window in whole percents and whether either is below the minimum, as `codex-model.mts --usage` reported them. */
15export type Budget = {
16 readonly primary: number | null;
17 readonly secondary: number | null;
18 readonly belowMinimum: boolean;
19};
20
21/** The state of the progress band, its list null between runs, written only through `step`. */
22export type Run = {
23 /** The list as the tool that wrote it last keeps it, replaced whole by a call of the other tool, never empty. */
24 readonly list: {
25 readonly tool: 'TodoWrite' | 'Tasks';
26 readonly items: readonly Item[];
27 } | null;
28 /** Holds the last read's answer, null before it and when Codex cannot be read. */
29 readonly budget: Budget | null;
30 /** Holds the whole percent left of Claude Code's current session, its 5-hour window, null before a reading and off a subscription. */
31 readonly claude: number | null;
32};
33
34type List = NonNullable<Run['list']>;
35
36/** One thing that happened in the session, parsed from an engine event at the hook boundary. */
37export type Event =
38 | { readonly kind: 'todosWritten'; readonly todos: readonly Item[] }
39 | { readonly kind: 'taskCreated'; readonly task: Item }
40 | {
41 readonly kind: 'taskEdited';
42 readonly id: string;
43 readonly title: string | undefined;
44 readonly status: ItemStatus | undefined;
45 }
46 | { readonly kind: 'taskDeleted'; readonly id: string }
47 | { readonly kind: 'personPrompted' }
48 | { readonly kind: 'cleared' }
49 | { readonly kind: 'codexRead'; readonly budget: Budget | null }
50 | { readonly kind: 'claudeMeasured'; readonly left: number | null };
51
52/** How a part of the line reads, plain for the progress, quiet for the Claude and the Codex usage, alarm below the Codex minimum. */
53export type Tone = 'plain' | 'quiet' | 'alarm';
54
55/** The Codex usage left as each window that reported it, such as `5h 62%`, alarm when either window is below the minimum. */
56export type CodexUsage = {
57 readonly windows: readonly [string, ...string[]];
58 readonly tone: Exclude<Tone, 'plain'>;
59};
60
61/** The Claude usage left of the current session as its window, such as `5h 77%`. */
62export type ClaudeUsage = {
63 readonly window: string;
64};
65
66/** What the band shows of the list. */
67export type Progress = {
68 /** The completed items over all items, such as `5/8`. */
69 readonly count: string;
70 /** The completed share of the items as a whole percentage rounded down, such as `62%`, so it reads `100%` only once every item is done. */
71 readonly filled: string;
72 /** The title of the item in progress or else of the first pending one on one line, null once every item is done. */
73 readonly title: string | null;
74};
75
76/** What the band shows for a run on every surface, before a surface lays it out, any of the Claude usage, the progress and the Codex usage but never none. */
77export type Band =
78 | {
79 readonly claude: ClaudeUsage;
80 readonly progress: Progress | null;
81 readonly codex: CodexUsage | null;
82 }
83 | {
84 readonly claude: null;
85 readonly progress: Progress;
86 readonly codex: CodexUsage | null;
87 }
88 | {
89 readonly claude: null;
90 readonly progress: null;
91 readonly codex: CodexUsage;
92 };
93
94export type Segment = {
95 readonly text: string;
96 readonly tone: Tone;
97};
98
99/** The band's one line as its parts in order, already cut to the band's width. */
100export type BandLine = readonly [Segment, ...Segment[]];
101
102export const INITIAL_RUN: Run = { list: null, budget: null, claude: null };
103
104/** The fewest columns between the progress and the Codex text at the right edge. */
105const MIN_GAP = 4;
106
107/** Applies one event to the run, the only way the run changes. */
108export function step(run: Run, event: Event): Run {
109 switch (event.kind) {
110 case 'todosWritten':
111 return settle(run, listOf('TodoWrite', event.todos));
112 case 'taskCreated':
113 return settle(run, withTask(run.list, event.task));
114 case 'taskEdited':
115 return settle(run, editedTask(run.list, event));
116 case 'taskDeleted':
117 return settle(run, withoutTask(run.list, event.id));
118 case 'personPrompted':
119 return dismissed(run);
120 case 'cleared':
121 return { ...run, list: null };
122 case 'codexRead':
123 return { ...run, budget: event.budget };
124 case 'claudeMeasured':
125 return { ...run, claude: event.left };
126 default:
127 return unreachable(event);
128 }
129}
130
131/** Reads what the band shows for the run, null when it has no Claude usage, no list and no Codex usage. */
132export function bandOf(run: Run): Band | null {
133 const claude = run.claude === null ? null : { window: `5h ${run.claude}%` };
134 const progress = run.list === null ? null : progressOf(run.list);
135 const codex = codexUsage(run.budget);
136
137 if (claude !== null) {
138 return { claude, progress, codex };
139 }
140
141 if (progress !== null) {
142 return { claude: null, progress, codex };
143 }
144
145 return codex === null ? null : { claude: null, progress: null, codex };
146}
147
148/** Lays out the band in characters at the given width, the terminal's one line, the Codex usage at the right edge and the Claude usage at the left edge, which gives way only once it and the head of the progress no longer fit the columns together. */
149export function bandLine(band: Band, columns: number): BandLine {
150 const codex = band.codex === null ? null : codexText(band.codex);
151
152 if (band.progress === null) {
153 return band.claude === null
154 ? [atRightEdge(codexText(band.codex), 0, columns)]
155 : fitted(claudeText(band.claude, ''), '', codex, columns);
156 }
157
158 const head = plain(headOf(band.progress));
159 const title = band.progress.title ?? '';
160 const claude = band.claude === null ? null : claudeText(band.claude, ' · ');
161
162 if (claude === null || width(claude.text) + width(head.text) > columns) {
163 return fitted(head, title, codex, columns);
164 }
165
166 return [claude, ...fitted(head, title, codex, columns - width(claude.text))];
167}
168
169/** Reads the one JSON line of `codex-model.mts --usage`, null when it is no usage report. */
170export function parseUsage(stdout: string): Budget | null {
171 const line = stdout.trim().split('\n').at(-1) ?? '';
172 let value: unknown;
173
174 try {
175 value = JSON.parse(line);
176 } catch {
177 return null;
178 }
179
180 if (typeof value !== 'object' || value === null) {
181 return null;
182 }
183
184 const report = value as Record<string, unknown>;
185 const { primary, secondary, belowMinimum } = report;
186
187 if (
188 typeof belowMinimum !== 'boolean' ||
189 !isPercentOrNull(primary) ||
190 !isPercentOrNull(secondary)
191 ) {
192 return null;
193 }
194
195 return { primary, secondary, belowMinimum };
196}
197
198function isPercentOrNull(value: unknown): value is number | null {
199 return (
200 value === null ||
201 (typeof value === 'number' &&
202 Number.isInteger(value) &&
203 value >= 0 &&
204 value <= 100)
205 );
206}
207
208/** Moves the run to its next list. */
209function settle(run: Run, next: List | null): Run {
210 // A finished list replaces a shown one and is dropped between runs, so a list rewritten after the person's prompt stays hidden.
211 if (next !== null && !hasOpen(next)) {
212 return { ...run, list: run.list === null ? null : next };
213 }
214
215 return { ...run, list: next };
216}
217
218/** Wraps items into a list, null for none. */
219function listOf(tool: List['tool'], items: readonly Item[]): List | null {
220 return items.length === 0 ? null : { tool, items };
221}
222
223/** Appends a created task to a task list, or starts a task list in place of a TodoWrite list or none. */
224function withTask(list: Run['list'], task: Item): List {
225 const items = list?.tool === 'Tasks' ? list.items : [];
226
227 return { tool: 'Tasks', items: [...items, task] };
228}
229
230/** Applies a task's new title and status, leaving the list as it is for a task it does not hold. */
231function editedTask(
232 list: Run['list'],
233 edit: Extract<Event, { kind: 'taskEdited' }>,
234): List | null {
235 if (list?.tool !== 'Tasks') {
236 return list;
237 }
238
239 return listOf(
240 'Tasks',
241 list.items.map(task =>
242 task.id === edit.id
243 ? {
244 ...task,
245 title: edit.title ?? task.title,
246 status: edit.status ?? task.status,
247 }
248 : task,
249 ),
250 );
251}
252
253/** Removes a deleted task, null when it was the last one. */
254function withoutTask(list: Run['list'], id: string): List | null {
255 if (list?.tool !== 'Tasks') {
256 return list;
257 }
258
259 return listOf(
260 'Tasks',
261 list.items.filter(task => task.id !== id),
262 );
263}
264
265/** Ends a finished run on the person's prompt and leaves an open one as it is. */
266function dismissed(run: Run): Run {
267 return run.list !== null && !hasOpen(run.list) ? { ...run, list: null } : run;
268}
269
270function unreachable(event: never): never {
271 throw new Error(`Unknown event ${JSON.stringify(event)}`);
272}
273
274function progressOf({ items }: List): Progress {
275 const done = items.filter(item => item.status === 'completed').length;
276 const current =
277 items.find(item => item.status === 'in_progress') ??
278 items.find(item => item.status === 'pending');
279
280 return {
281 count: `${done}/${items.length}`,
282 filled: `${Math.floor((done / items.length) * 100)}%`,
283 title: current === undefined ? null : singleLine(current.title),
284 };
285}
286
287function hasOpen(list: List): boolean {
288 return list.items.some(item => item.status !== 'completed');
289}
290
291/** Turns every run of line breaks and control characters into one space, so a title draws on one line. */
292function singleLine(title: string): string {
293 return title.replace(/[\p{Cc}\p{Zl}\p{Zp}]+/gu, ' ').trim();
294}
295
296/** Words each window that reported the usage left, alarm when either window has less left than the Codex minimum, whether credits pay or not. */
297function codexUsage(budget: Budget | null): CodexUsage | null {
298 if (budget === null) {
299 return null;
300 }
301
302 const [first, ...rest] = [
303 { label: '5h', left: budget.primary },
304 { label: 'week', left: budget.secondary },
305 ]
306 .filter(window => window.left !== null)
307 .map(window => `${window.label} ${window.left}%`);
308
309 if (first === undefined) {
310 return null;
311 }
312
313 return {
314 windows: [first, ...rest],
315 tone: budget.belowMinimum ? 'alarm' : 'quiet',
316 };
317}
318
319/** Writes the usage as one text, with `(below minimum)` after it in the alarm tone. */
320function codexText(usage: CodexUsage): Segment {
321 const text = `Codex ${usage.windows.join(' · ')}`;
322
323 return usage.tone === 'alarm'
324 ? { text: `${text} (below minimum)`, tone: 'alarm' }
325 : { text, tone: 'quiet' };
326}
327
328/** Writes the Claude usage as one text with the separator from the progress after it. */
329function claudeText(usage: ClaudeUsage, separator: string): Segment {
330 return { text: `Claude ${usage.window}${separator}`, tone: 'quiet' };
331}
332
333/** Writes the part of the progress that is never cut, the count with `done` or with the dot before the title. */
334function headOf({ count, title }: Progress): string {
335 if (title === null) {
336 return `${count} done`;
337 }
338
339 return title === '' ? count : `${count} · `;
340}
341
342function plain(text: string): Segment {
343 return { text, tone: 'plain' };
344}
345
346/** Counts a character as one column, with the outer Text cutting at the cell edge for a wide glyph. */
347function width(text: string): number {
348 return [...text].length;
349}
350
351/** Cuts the title so head, title, gap and Codex text fit the columns, and pushes the Codex text to the right edge, leaving it out when the title would vanish. */
352function fitted(
353 head: Segment,
354 title: string,
355 codex: Segment | null,
356 columns: number,
357): BandLine {
358 const roomBesideCodex =
359 codex === null
360 ? 0
361 : columns - width(head.text) - MIN_GAP - width(codex.text);
362
363 const keepsCodex = codex !== null && roomBesideCodex > 0;
364 const room = keepsCodex ? roomBesideCodex : columns - width(head.text);
365 const cut =
366 width(title) <= Math.max(0, room)
367 ? title
368 : `${[...title].slice(0, Math.max(0, room - 1)).join('')}…`;
369
370 const progress: Segment = { text: `${head.text}${cut}`, tone: head.tone };
371
372 if (!keepsCodex) {
373 return [progress];
374 }
375
376 return [progress, atRightEdge(codex, width(progress.text), columns)];
377}
378
379/** Pads the Codex text after the columns already taken so it ends at the right edge, with no pad where it does not fit. */
380function atRightEdge(codex: Segment, taken: number, columns: number): Segment {
381 const pad = Math.max(0, columns - taken - width(codex.text));
382
383 return { text: `${' '.repeat(pad)}${codex.text}`, tone: codex.tone };
384}
385