Claude Code skills for lean agentic workflow: tier routing, discovery, design mockups and diagrams, plan drafting, tests-first, subagent coordination…

Lean agentic workflow for Claude Code, Codex, and OpenCode.
Condux routes a dev task into the right execution tier — Small, Medium, or Large — and runs only the steps that tier actually needs. No discovery phase for a typo. No plan document for a two-line fix. No test suite running mid-implementation.
The design bet is proportional effort. Most agentic workflow tooling applies the same heavy pipeline to every task, so people stop using it for small work and it stops being a workflow at all. Condux makes the tier an explicit, user-confirmed decision and loads downstream skills lazily, only when a step reaches for one.
Claude Code
/plugin marketplace add jabworks/agentic-toolkit
/plugin install condux@jabworks-agentic-toolkit
Codex
codex plugin marketplace add jabworks/agentic-toolkit
codex plugin add condux@jabworks-agentic-toolkit
OpenCode — one line, via the @jabworks/condux npm package, which bundles these skills and self-registers them on config.skills.paths:
// opencode.json
{
"plugin": ["@jabworks/condux"]
}
Codex needs two more things, and no plugin can do either for you: Codex's experimental hooks feature has to be enabled, and the five specialist agents are standalone TOMLs because the Codex plugin format has no agents/ component.
node install.mjs # detect, register, verify, report
node install.mjs --dry-run # see what it would change first
INSTALL.md is the same procedure by hand, and covers what differs between a plugin install and npx skills add. To check an existing install rather than change it, run /condux:condux-doctor — or node install.mjs --uninstall to reverse it.
condux conflicts with superpowers. Run one or the other.
This is stated plainly because condux owes it a debt: condux reworks superpowers' skill-orchestration ideas around proportional effort — tiered routing, lazy loading, soft gates — instead of always-on maximalism. The consequence of a rework is that the two are near-complete functional twins.
They collide hardest at the point neither can yield. Both register a SessionStart hook on the same matcher, and each injects a router telling the agent that every dev task starts with it:
superpowers SessionStart startup|clear|compact → using-superpowers/SKILL.md
condux SessionStart startup|clear|compact → routing.md
Nothing errors. The agent simply follows whichever contract it read last, and which one that is can change between sessions. Below that, 11 of superpowers' 14 skills answer the same question as 8 of condux's 14:
| superpowers | condux |
|---|---|
using-superpowers | workflow |
brainstorming | discovery |
writing-plans | draft-plan |
executing-plans · subagent-driven-development | subagent-execution |
dispatching-parallel-agents | subagent-deployment |
test-driven-development | test-first-development |
systematic-debugging | root-cause-analysis |
verification-before-completion | preflight |
requesting-code-review · receiving-code-review | code-review |
(Verified against superpowers 6.2.0. using-git-worktrees and finishing-a-development-branch are the part you would actually lose — elsewhere in this toolkit, git-operations and release cover that ground, and writing-skills is answered by toolkit-ops.)
Both install.mjs and /condux:condux-doctor detect this and print the removal command. Neither runs it. Uninstalling a plugin you chose is not a decision an installer gets to make:
/plugin uninstall superpowers@claude-plugins-official
The same check covers loose skills under ~/.claude/skills, ~/.agents/skills, $CODEX_HOME/skills and OpenCode's skills/ — a machine can carry the overlap through npx skills add without the plugin ever being installed.
The plan-review skill is an independent reimplementation inspired by Plannotator — no shared code and no third-party runtime dependency. It is not in the conflict registry: running both is untidy rather than harmful, and no detection surface for it was verified. Adding an entry needs one, checked against a real install — a wrong name in a shipped warning is worse than no warning.
The registry lives at skills/condux-doctor/conflicts.json. Additions are a data edit.
Start with /workflow — it is both the entry point and the operating manual.
| Skill | What it does |
|---|---|
workflow | Routes any task to Small / Medium / Large and carries the operating rules. Every dev task starts here. |
discovery | Turns a rough idea into a signed-off design. Goal-level questions that become the feature's PRD, alternatives, then a detail round that feeds the spec. |
blueprint | Design-time visual clarity: HTML wireframes and full renders for UI in the house token language (schematic wireframe mode, full render mode), inline-SVG diagrams for data models, flows, and architecture. |
draft-plan | Converts a signed-off design into lean task cards — what, why, gotchas, dependencies. |
technical-spec | Durable feature specs under specs/ — the PRD, decisions, contracts, quirks — with a live preview. |
plan-review | Renders a plan in a local browser for inline annotation, returns approve / revise / deny. |
test-first-development | Tests before implementation. Opt-in, one upfront consent, never silently rewrites an existing spec. |
subagent-execution | Executes a plan with named specialist agents; resumable from a progress ledger. |
subagent-deployment | Fans out 2+ genuinely independent tasks in a single message. |
root-cause-analysis | Root-cause-first debugging. No fix proposed before the investigation completes. |
preflight | "Am I actually done?" — catches skipped steps and silent regressions before the quality gate. |
finalize | The single end-of-task quality gate: typecheck → lint → format → test, in order, once. |
code-review | Diagnostic report by severity. On request only; never auto-triggers, never fixes. |
live-verification | Drives the real UI, endpoint, or CLI and checks each claim against observed behaviour before you push — including Android apps on an emulator via adb; install agent-device for a richer driver (optional). |
research | Cited multi-source research for dev questions — parallel scout agents write cited notes, and a decision-ready report lands in .condux/research/. |
condux-doctor | Is condux actually working on this host? Runs the SessionStart hook, resolves the Codex Stop hook, checks the agents shipped. |
Five named specialist agents ship alongside them: explorer (read-only codebase navigation), researcher (external API verification), planner (design → plan), coder (executes a provided plan), and scout (one angle of a /condux:research survey). Condux implements directly by default — spawning an agent requires a concrete justification.
On Claude Code (terminal and desktop app) condux also ships a mod: a small module that draws in the session instead of asking you to open a file. It is read-only: it never writes to .condux/, runs a command or fills your prompt (it does open itself once; see below).
/condux | Opens a live board of your plans: each task as done, next ready, ready or blocked, the ledger's wrap-up lines, and a task's card on expand. Plans with no progress ledger show as "state unknown" rather than guessing. /condux all lists every plan, including older finished ones. The stale and no-ledger groups show their newest five and count the rest. |
| Opens itself | The first time an agent writes a progress ledger in a session (the moment a saved plan starts being implemented) the board opens beside the chat without taking your keyboard. Once per session; close it and it stays closed. Set CONDUX_AUTO_OPEN=0 to turn it off. |
| Status line | One line under the prompt, such as plan: my-feature · 4/6 · next: Task 5, for the open plan whose ledger changed most recently. It appears only in repositories that have a .condux/ directory, and clears when nothing is open. |
The board reads the plans and progress ledgers under .condux/ in the repository you are in, and polls them every few seconds, so it follows an agent that is working through a plan on its own. If the mod fails to load, the skills and the routing hook are unaffected; you get one log line. Codex, OpenCode and Cursor do not load mods, so nothing changes for them. The design is in specs/condux-mod/.
Two tiers, split by durability:
| Tier | Location | Committed |
|---|---|---|
| Durable — tech specs | <git-root>/specs/ | yes |
| Working state — designs, plans, progress, scratch | <git-root>/.condux/ | no (gitignored) |
.condux/ is created on demand and condux asks before touching your .gitignore. It never writes to the repo root, the CWD, or your project's docs/.
Condux is designed to be auditable in a single sitting.
{"type": "commonjs"} — there is no node_modules, no install step, and no third-party code in the execution path.127.0.0.1 explicitly. Nothing is reachable off the machine.node on a file inside the plugin root, addressed through the host's own plugin-root variable (${CLAUDE_PLUGIN_ROOT} on Claude Code, ${PLUGIN_ROOT} on Codex).hooks/codex-hooks.json sets "timeout": 345600. That is deliberate, not a typo.
The Stop hook opens the plan-review server and blocks until you make a decision in the browser. The timeout is the maximum time it will wait for a human — so it is sized for "I stepped away and came back tomorrow," not for a machine operation. A conventional 30- or 60-second timeout would kill the review while you were reading it. The server exits as soon as a decision is delivered; the timeout is a ceiling, never a delay.
Source of truth is skills/ in jabworks/agentic-toolkit — this directory is a generated install mirror. File issues against the main repo.
MIT — see LICENSE.
hooks/mod/register.tsx 26 lines1import { type Register } from 'claude-code';
2
3import { armStatus, disarmStatus, registerBoard, resumeBoard } from './board/board';
4
5// Features register here, one `registerX(on)` each, so a new slice is a new file and one line rather than a rewrite.
6// session.start is the exception: the validator rejects two hooks on it without a matcher, so this one hook starts
7// every feature through a plain function it imports.
8export const register: Register = on => {
9 on('session.start', async ($, e, next) => {
10 // A literal name lets `claude plugin validate` see that each command.run hook answers only its own command.
11 await $.command.register({ name: 'condux', description: "Show this repo's condux plans and their progress" });
12 await resumeBoard($);
13 await armStatus($);
14
15 return next(e);
16 });
17
18 on('session.end', ($, e, next) => {
19 disarmStatus($, e.reason);
20
21 return next(e);
22 });
23
24 registerBoard(on);
25};
26hooks/mod/board/board.tsx 798 lines1import { type EngineInterface, type On, type RenderChildren, type Timer, atom, read, update } from 'claude-code';
2
3import { barSvg } from '../kit/charts';
4import { locateWorkingState, type WorkingState } from '../kit/locate';
5import { joinPath } from '../kit/paths';
6import { type BoardPlan, type BoardSnapshot, type Ledger, type Plan, type PlanStatus } from '../types';
7import { badges } from './badges';
8import { graphSvg, hasGraph, STATE_HEX } from './graph';
9import { parseLedger } from './parse-ledger';
10import { parsePlan } from './parse-plan';
11import { deriveBoard, statusText } from './state';
12import { type Stage, stepperStages } from './stepper';
13import {
14 activity,
15 boardText,
16 groupPlans,
17 isActive,
18 planCount,
19 statusLabel,
20 taskGlyph,
21 unledgered,
22 waitLabel,
23} from './text';
24
25const PANE = 'condux';
26const POLL_MS = 3000;
27// A surface that never answers `$.ui.open` must not leave /condux spinning (docket's did, in the desktop's Code tab).
28const OPEN_TIMEOUT_MS = 2000;
29
30const board = atom({ plugin: 'condux', key: 'board' } as const, null);
31const toggled = atom({ plugin: 'condux', key: 'toggled' } as const, []);
32const filter = atom({ plugin: 'condux', key: 'filter' } as const, '');
33
34type WorkingFile = { kind: 'plan' | 'ledger'; name: string; mtimeMs: number };
35
36async function listFiles($: EngineInterface, dir: string, kind: WorkingFile['kind']): Promise<WorkingFile[]> {
37 // A directory that does not exist yet is not an error: nothing has been planned.
38 const entries = await $.fs.list(dir).catch(() => []);
39
40 return entries
41 .filter(entry => entry.kind === 'file' && /\.md$/i.test(entry.name))
42 .map(entry => ({ kind, name: entry.name, mtimeMs: entry.mtimeMs }))
43 .sort((a, b) => a.name.localeCompare(b.name));
44}
45
46// Everything that touches `$` is a function declaration, not an arrow: register.tsx reaches this file through an
47// import, and past an import `claude plugin validate` follows `$` only into declarations. The engine likewise follows
48// `$` only into functions declared at the top of the file, so it all lives here.
49let poll: Timer | undefined;
50let inFlight: Promise<void> | undefined;
51let located: WorkingState | null | undefined;
52let lastCwd: string | undefined;
53let fingerprint: string | undefined;
54let cache: { plans: Plan[]; ledgers: Ledger[]; updatedMs: number } | undefined;
55let failures = 0;
56// The status line rides the same poll as the pane: one timer, two reasons to keep it running.
57let isStatusOn = false;
58let lastStatus: string | null | undefined;
59
60// Missing and unreadable boards are rewritten on every poll; skipping identical writes keeps the pane from redrawing.
61async function setBoard($: EngineInterface, value: BoardSnapshot): Promise<void> {
62 const current = await read($, board);
63
64 if (JSON.stringify(current) === JSON.stringify(value)) {
65 return;
66 }
67
68 await update($, board, () => value);
69}
70
71async function load($: EngineInterface, isForced: boolean): Promise<void> {
72 try {
73 const cwd = await $.session.cwd();
74
75 // Walk again on a cwd change (a worktree switch) and while nothing is found, so a new `.condux` shows up.
76 if (isForced || cwd !== lastCwd || !located) {
77 const found = await locateWorkingState($, cwd);
78
79 if (found?.dir !== located?.dir) {
80 fingerprint = undefined;
81 }
82
83 lastCwd = cwd;
84 located = found;
85 }
86
87 if (!located) {
88 failures = 0;
89 await setBoard($, { kind: 'missing', searchedFrom: cwd });
90
91 return;
92 }
93
94 const plansDir = joinPath(located.dir, 'plans');
95 const progressDir = joinPath(located.dir, 'progress');
96 const files = [...(await listFiles($, plansDir, 'plan')), ...(await listFiles($, progressDir, 'ledger'))];
97 // Each file's own time, not the directory's: appending to a ledger does not change its directory's.
98 const next = files.map(file => `${file.kind}:${file.name}:${file.mtimeMs}`).join('|');
99
100 if (isForced || next !== fingerprint || cache === undefined) {
101 const plans = await Promise.all(
102 files
103 .filter(file => file.kind === 'plan')
104 .map(async file => parsePlan(await $.fs.read(joinPath(plansDir, file.name)), file.name)),
105 );
106 const ledgers = await Promise.all(
107 files
108 .filter(file => file.kind === 'ledger')
109 .map(async file =>
110 parseLedger(await $.fs.read(joinPath(progressDir, file.name)), file.name, file.mtimeMs),
111 ),
112 );
113
114 cache = { plans, ledgers, updatedMs: files.reduce((latest, file) => Math.max(latest, file.mtimeMs), 0) };
115 fingerprint = next;
116 }
117
118 const now = await $.clock.now();
119
120 failures = 0;
121 await setBoard($, {
122 kind: 'found',
123 root: located.root,
124 plans: deriveBoard(cache.plans, cache.ledgers, now),
125 updatedMs: cache.updatedMs,
126 });
127 } catch (error) {
128 const reason = error instanceof Error ? error.message : String(error);
129
130 failures += 1;
131 fingerprint = undefined;
132 located = undefined;
133
134 // An agent that rewrites a file leaves it missing for a moment: one failed poll keeps the board.
135 if (!isForced && failures < 2) {
136 return;
137 }
138
139 await setBoard($, { kind: 'unreadable', reason }).catch(() => undefined);
140 }
141}
142
143// One load at a time, so a slow filesystem cannot stack polls; a forced load waits its turn instead of being lost.
144async function refresh($: EngineInterface, isForced: boolean): Promise<void> {
145 if (inFlight) {
146 if (!isForced) {
147 return;
148 }
149
150 await inFlight;
151
152 return refresh($, true);
153 }
154
155 inFlight = load($, isForced).finally(() => {
156 inFlight = undefined;
157 });
158
159 return inFlight;
160}
161
162const stopPolling = (): void => {
163 poll?.cancel();
164 poll = undefined;
165};
166
167// Pins the one-line summary under the prompt; a repeat of what is already pinned is not sent again.
168async function publishStatus($: EngineInterface): Promise<void> {
169 if (!isStatusOn) {
170 return;
171 }
172
173 const snapshot = await read($, board);
174 const text = snapshot?.kind === 'found' ? statusText(snapshot.plans) : null;
175
176 if (text === lastStatus) {
177 return;
178 }
179
180 lastStatus = text;
181 $.ui.status(text ?? undefined);
182}
183
184// ui.close hooks do not run when the engine unloads a pane whose drawing threw, so each tick checks the pane is up. The
185// timer then stops only if the status line has no use for it either.
186async function tick($: EngineInterface): Promise<void> {
187 const panes = await $.ui.panes().catch(() => undefined);
188
189 if (!isStatusOn && panes !== undefined && !panes.some(pane => pane.id === PANE)) {
190 stopPolling();
191
192 return;
193 }
194
195 await refresh($, false);
196 await publishStatus($);
197}
198
199function startPolling($: EngineInterface): void {
200 if (poll) {
201 return;
202 }
203
204 poll = $.clock.every(POLL_MS, () => {
205 void tick($);
206 });
207}
208
209type OpenOutcome = { isPlaced: true } | { isPlaced: false; reason: string };
210
211// `focus` is a request the surface may refuse; left out, the pane opens without taking the keyboard, which is what an
212// open the person did not ask for must do.
213function openPane($: EngineInterface, isFocused: boolean): Promise<OpenOutcome> {
214 return Promise.race<OpenOutcome>([
215 $.ui.open(isFocused ? { id: PANE, title: 'condux', focus: true } : { id: PANE, title: 'condux' }),
216 $.clock.sleep(OPEN_TIMEOUT_MS).then(() => ({ isPlaced: false, reason: 'this surface did not answer' })),
217 ]);
218}
219
220function toggle($: EngineInterface, key: string): Promise<unknown> {
221 return update($, toggled, list => (list.includes(key) ? list.filter(item => item !== key) : [...list, key]));
222}
223
224function setFilter($: EngineInterface, value: string): Promise<unknown> {
225 return update($, filter, () => value);
226}
227
228const USAGE = [
229 'Usage:',
230 ' /condux open the plan and progress board',
231 ' /condux all list every plan, including older finished ones',
232].join('\n');
233
234const clip = (text: string, room: number): string =>
235 text.length > room ? `${text.slice(0, Math.max(1, room - 1))}…` : text;
236
237const STATE_COLOR = { done: 'success', 'next-ready': 'claude', ready: 'text', blocked: 'subtle' } as const;
238
239// The status pill on the desktop's plan row: a short word on a mid-tone fill.
240const PILL: Record<PlanStatus, { label: string; hex: string }> = {
241 'no-ledger': { label: 'no ledger', hex: '#8a8a85' },
242 'not-started': { label: 'not started', hex: '#2f6db5' },
243 'in-progress': { label: 'in progress', hex: '#c8643c' },
244 'tasks-done': { label: 'wrapping up', hex: '#b8860b' },
245 complete: { label: 'complete', hex: '#3f8f5b' },
246 stale: { label: 'stale', hex: '#8a8a85' },
247};
248
249const DAY_MS = 86_400_000;
250
251// The desktop row's right-hand columns, in cells.
252const BAR_CELLS = 12;
253const COUNT_CELLS = 13;
254const PILL_CELLS = 13;
255const AGE_CELLS = 6;
256// Those four plus the gaps between them: what the title must leave free.
257const CLUSTER_CELLS = BAR_CELLS + COUNT_CELLS + PILL_CELLS + AGE_CELLS + 6;
258
259// A graph past this many tasks is a tangle; it sits behind a toggle instead of opening with the plan.
260const GRAPH_OPEN_MAX = 15;
261
262const ageText = (days: number): string => (days === 0 ? 'today' : `${days}d`);
263
264const stageGlyph = (stage: Stage): string => {
265 if (stage.isDone) {
266 return '●';
267 }
268
269 return stage.isGhost ? '◌' : '○';
270};
271
272// A task's mark colour: neutral for a plan with no ledger, red for a cycle, otherwise by state.
273const glyphColor = (plan: BoardPlan, boardTask: BoardPlan['tasks'][number]): string => {
274 if (plan.status === 'no-ledger') {
275 return 'subtle';
276 }
277
278 return boardTask.inCycle ? 'error' : STATE_COLOR[boardTask.state];
279};
280
281// The board opens itself once, the first time an agent writes a progress ledger: that is the moment a saved plan starts
282// being implemented, so a typo fix or a chat never opens anything. A person who closes it is not asked again, and
283// CONDUX_AUTO_OPEN=0 turns it off. Every failure is swallowed: this runs beside a tool call and must never touch it.
284let hasAutoOpened = false;
285
286async function autoOpen($: EngineInterface): Promise<void> {
287 if (hasAutoOpened) {
288 return;
289 }
290
291 hasAutoOpened = true;
292
293 try {
294 if ((await $.env.get('CONDUX_AUTO_OPEN')) === '0') {
295 return;
296 }
297
298 const panes = await $.ui.panes().catch(() => []);
299
300 if (panes.some(pane => pane.id === PANE)) {
301 return;
302 }
303
304 const opened = await openPane($, false);
305
306 if (!opened.isPlaced) {
307 return;
308 }
309
310 await refresh($, true);
311 await armStatus($);
312 startPolling($);
313 } catch {
314 // The board is an extra; the agent's write goes on.
315 }
316}
317
318// Called from register.tsx's one session.start hook (the validator rejects two un-matchered hooks on one event): a pane
319// that survived a hot reload resumes its polling.
320// A function declaration, not an arrow: past an import the validator follows `$` only into declarations.
321export async function resumeBoard($: EngineInterface): Promise<void> {
322 const panes = await $.ui.panes();
323
324 if (panes.some(pane => pane.id === PANE)) {
325 void refresh($, true);
326 startPolling($);
327 }
328}
329
330// Arms the status line: a session-long timer that runs only where a working-state directory exists, so a repository
331// that never used condux never pays for a poll. One that gains `.condux` mid-session is picked up the next time `/condux` is
332// run. After a hot reload `session.start` fires again, which re-arms this with fresh module state.
333export async function armStatus($: EngineInterface): Promise<void> {
334 const found = await locateWorkingState($, await $.session.cwd());
335
336 if (found === null) {
337 return;
338 }
339
340 isStatusOn = true;
341 await refresh($, true);
342 await publishStatus($);
343 startPolling($);
344}
345
346// Called from session.end. A `/clear` ends the session without a new session.start, and the process goes on with the
347// same plans, so it keeps the line; anything else is the end of the process and clears it.
348export function disarmStatus($: EngineInterface, reason: string): void {
349 if (reason === 'clear') {
350 return;
351 }
352
353 isStatusOn = false;
354 lastStatus = undefined;
355 $.ui.status(undefined);
356 stopPolling();
357}
358
359const LEDGER_FILE_RE = /(?:^|[\\/])\.condux[\\/]progress[\\/][^\\/]+\.md$/;
360// A shell write into the ledger: a redirect or tee naming it, not a `cat` that only reads it.
361const LEDGER_SHELL_RE = /(?:>>?|\btee\b)[^\n|;&]*\.condux\/progress\/\S+\.md/;
362
363export const registerBoard = (on: On): void => {
364 on('tool.call', { tool: ['Write', 'Edit'] }, ($, e, next) => {
365 if (typeof e.file_path === 'string' && LEDGER_FILE_RE.test(e.file_path)) {
366 void autoOpen($);
367 }
368
369 return next(e);
370 });
371
372 on('tool.call', { tool: 'Bash' }, ($, e, next) => {
373 if (typeof e.command === 'string' && LEDGER_SHELL_RE.test(e.command)) {
374 void autoOpen($);
375 }
376
377 return next(e);
378 });
379
380 on('command.run', { command: 'condux' }, async ($, e) => {
381 const argument = e.args.trim();
382
383 if (argument === 'all') {
384 await refresh($, true);
385
386 return { text: boardText(await read($, board), true) };
387 }
388
389 if (argument !== '') {
390 return { text: USAGE };
391 }
392
393 const opened = await openPane($, true);
394
395 await refresh($, true);
396 await armStatus($);
397
398 if (!opened.isPlaced) {
399 // No pane here, so nothing polls; the board goes to the transcript once.
400 return {
401 text: `The condux pane is not available here (${opened.reason}).\n\n${boardText(await read($, board), false)}`,
402 };
403 }
404
405 startPolling($);
406
407 // No text: the pane is the output, and a transcript line would cost the model tokens for nothing.
408 return {};
409 });
410
411 on('ui.close', ($, e, next) => {
412 if (e.id === PANE && !isStatusOn) {
413 stopPolling();
414 }
415
416 return next(e);
417 });
418
419 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
420 const elements = $.ui.resolve(e);
421 const { Box, Button, Markdown, Text } = elements;
422 // Vector drawings and a filter field are the desktop's; the terminal's cells cannot round a block, and typing in a
423 // field there would swallow the hotkeys. (`in` cannot tell the tables apart: key on the surface.)
424 const Svg =
425 e.surface === 'terminal' ? undefined : (elements as Extract<typeof elements, { Svg: unknown }>).Svg;
426 const Input =
427 e.surface === 'desktop' || e.surface === 'vscode'
428 ? (elements as Extract<typeof elements, { Svg: unknown; Input: unknown }>).Input
429 : undefined;
430 const snapshot = await read($, board);
431 const open = await read($, toggled);
432 const filterText = await read($, filter);
433 const now = await $.clock.now();
434 // Drawn twice as wide as the pane and clipped to one row: a proportional font makes the glyph narrower or wider
435 // than a cell, and an exact-width string wrapped into a second, partial line there.
436 const windowWidth = Math.max(20, e.props.bodyColumns - 4);
437 const rowWidth = Math.max(16, windowWidth - 4);
438 const divider = (glyph: string, width: number) => (
439 <Box
440 height={1}
441 overflow='hidden'
442 >
443 <Text color='subtle'>{glyph.repeat(width * 2)}</Text>
444 </Box>
445 );
446
447 // The hints sit under the header: a pane has no fixed footer, its whole body scrolls as one.
448 const frame = (count: string, body: RenderChildren, hints: string) => (
449 <Box
450 flexDirection='column'
451 borderStyle='round'
452 borderColor='claude'
453 paddingX={1}
454 >
455 <Box justifyContent='space-between'>
456 <Box gap={1}>
457 <Text
458 bold
459 color='claude'
460 >
461 condux
462 </Text>
463 <Text color='subtle'>{count}</Text>
464 </Box>
465 <Button
466 key='refresh'
467 hotkey='r'
468 label='Refresh'
469 dimColor
470 onPress={() => refresh($, true)}
471 />
472 </Box>
473 <Text color='subtle'>{hints}</Text>
474 {divider('─', windowWidth)}
475 {body}
476 </Box>
477 );
478
479 if (snapshot === null) {
480 return frame('', <Text color='subtle'>Loading the plans…</Text>, 'r refresh');
481 }
482
483 if (snapshot.kind === 'missing') {
484 return frame(
485 'none found',
486 <Text>No condux working state found in {snapshot.searchedFrom} or above it.</Text>,
487 'r refresh',
488 );
489 }
490
491 if (snapshot.kind === 'unreadable') {
492 return frame('unreadable', <Text color='error'>Could not read the plans: {snapshot.reason}</Text>, 'r retry');
493 }
494
495 const updated = new Date(snapshot.updatedMs);
496 const updatedAt = `${String(updated.getHours()).padStart(2, '0')}:${String(updated.getMinutes()).padStart(2, '0')}`;
497 const needle = filterText.trim().toLowerCase();
498 const shown =
499 needle === ''
500 ? snapshot.plans
501 : snapshot.plans.filter(entry => `${entry.plan.title} ${entry.plan.slug}`.toLowerCase().includes(needle));
502 const groups = groupPlans(shown, needle === '');
503
504 const renderTask = (entry: BoardPlan, boardTask: BoardPlan['tasks'][number]) => {
505 const key = `task:${entry.plan.file}:${boardTask.task.number}`;
506 const isOpen = open.includes(key);
507 const wait = waitLabel(entry, boardTask);
508 const nameRoom = Math.max(8, rowWidth - 14 - wait.length);
509
510 return (
511 <Box flexDirection='column'>
512 <Button
513 key={key}
514 plain
515 onPress={() => toggle($, key)}
516 >
517 <Text color={glyphColor(entry, boardTask)}>{`${taskGlyph(entry, boardTask)} `}</Text>
518 <Text bold={boardTask.state === 'next-ready'}>
519 {`Task ${boardTask.task.number} ${clip(boardTask.task.name, nameRoom)}`}
520 </Text>
521 <Text color='subtle'>{wait === '' ? '' : ` ${wait}`}</Text>
522 </Button>
523 {isOpen && (
524 <Box
525 marginLeft={2}
526 marginY={1}
527 flexDirection='column'
528 >
529 {boardTask.task.body === '' ? (
530 <Text color='subtle'>No card text.</Text>
531 ) : (
532 <Markdown text={boardTask.task.body} />
533 )}
534 </Box>
535 )}
536 </Box>
537 );
538 };
539
540 // Plan chrome, shared by both surfaces: how alive the agent looks, the facts best read from the ledger, and the
541 // pipeline from the first task to the commit.
542 const renderChrome = (entry: BoardPlan) => {
543 const alive = activity(entry);
544 const facts = badges(entry);
545 const stages = stepperStages(entry);
546 const graphKey = `graph:${entry.plan.file}`;
547 const isBig = entry.tasks.length > GRAPH_OPEN_MAX;
548 const isGraphShown = !isBig || open.includes(graphKey);
549 const graph = Svg !== undefined && hasGraph(entry) && isGraphShown ? graphSvg(entry, 560) : null;
550
551 return (
552 <Box
553 flexDirection='column'
554 gap={1}
555 marginY={1}
556 >
557 {alive !== null && (
558 <Text
559 key={`activity:${entry.plan.file}`}
560 color={alive.tone}
561 >
562 {alive.label}
563 </Text>
564 )}
565 {facts.length > 0 && (
566 <Text
567 key={`badges:${entry.plan.file}`}
568 color='subtle'
569 >
570 {facts.join(' · ')}
571 </Text>
572 )}
573 {stages.length > 0 && (
574 <Box
575 key={`stepper:${entry.plan.file}`}
576 gap={2}
577 flexWrap='wrap'
578 >
579 {stages.map(stage => (
580 <Text
581 key={`stage:${entry.plan.file}:${stage.label}`}
582 color={stage.isDone ? 'success' : 'subtle'}
583 dimColor={stage.isGhost}
584 >
585 {`${stageGlyph(stage)} ${stage.label}`}
586 </Text>
587 ))}
588 </Box>
589 )}
590 {Svg !== undefined && hasGraph(entry) && isBig && (
591 <Button
592 key={graphKey}
593 plain
594 onPress={() => toggle($, graphKey)}
595 >
596 <Text color='claude'>{isGraphShown ? '▾ ' : '▸ '}</Text>
597 <Text color='subtle'>{`dependency graph · ${entry.tasks.length} tasks`}</Text>
598 </Button>
599 )}
600 {graph !== null && Svg !== undefined && (
601 <Svg
602 source={graph.svg}
603 alt={`Task dependencies of ${entry.plan.title}`}
604 width={graph.width}
605 height={graph.height}
606 isInteractive
607 />
608 )}
609 </Box>
610 );
611 };
612
613 const renderPlanHeader = (entry: BoardPlan, key: string, isOpen: boolean) => {
614 if (Svg === undefined) {
615 return (
616 <Button
617 key={key}
618 plain
619 onPress={() => toggle($, key)}
620 >
621 <Text color='claude'>{isOpen ? '▾ ' : '▸ '}</Text>
622 <Text bold>{clip(entry.plan.title, Math.max(12, rowWidth - 36))}</Text>
623 <Text color='subtle'>{` ${planCount(entry)}${statusLabel(entry.status)}`}</Text>
624 </Button>
625 );
626 }
627
628 // Title on the left; what you scan for on the right: progress, count, status, age.
629 const total = entry.plan.tasks.length;
630 const pill = PILL[entry.status];
631 const dated = entry.plan.date === null ? Number.NaN : Date.parse(`${entry.plan.date}T00:00:00Z`);
632 const age = Number.isNaN(dated) ? null : Math.max(0, Math.floor((now - dated) / DAY_MS));
633 const shares = [
634 { name: 'Done', count: entry.doneCount, color: STATE_HEX.done },
635 { name: 'Left', count: Math.max(0, total - entry.doneCount), color: STATE_HEX.blocked },
636 ];
637
638 return (
639 <Box
640 key={`planrow:${entry.plan.file}`}
641 justifyContent='space-between'
642 alignItems='center'
643 >
644 <Button
645 key={key}
646 plain
647 onPress={() => toggle($, key)}
648 >
649 <Text color='claude'>{isOpen ? '▾ ' : '▸ '}</Text>
650 <Text bold>{clip(entry.plan.title, Math.max(12, rowWidth - CLUSTER_CELLS - 4))}</Text>
651 </Button>
652 <Box
653 gap={2}
654 alignItems='center'
655 flexShrink={0}
656 >
657 {/* Fixed-width columns, so the bars, counts and pills line up from one row to the next. */}
658 <Box
659 width={BAR_CELLS}
660 flexShrink={0}
661 >
662 {entry.status !== 'no-ledger' && total > 0 && (
663 <Svg
664 source={barSvg(shares, 100, 10)}
665 alt={`${entry.doneCount} of ${total} tasks done`}
666 width={100}
667 height={10}
668 />
669 )}
670 </Box>
671 <Box
672 width={COUNT_CELLS}
673 flexShrink={0}
674 justifyContent='flex-end'
675 >
676 <Text color='subtle'>{planCount(entry).replace(/ · $/, '')}</Text>
677 </Box>
678 <Box
679 width={PILL_CELLS}
680 flexShrink={0}
681 backgroundColor={pill.hex}
682 justifyContent='center'
683 >
684 <Text color='#ffffff'>{pill.label}</Text>
685 </Box>
686 <Box
687 width={AGE_CELLS}
688 flexShrink={0}
689 justifyContent='flex-end'
690 >
691 <Text color='subtle'>{age === null ? '' : ageText(age)}</Text>
692 </Box>
693 </Box>
694 </Box>
695 );
696 };
697
698 const renderPlan = (entry: BoardPlan) => {
699 const key = `plan:${entry.plan.file}`;
700 // An active plan is open by default and a finished one closed; a toggle flips whichever default applies.
701 const isOpen = isActive(entry) !== open.includes(key);
702
703 return (
704 <Box flexDirection='column'>
705 {renderPlanHeader(entry, key, isOpen)}
706 {isOpen && (
707 <Box
708 flexDirection='column'
709 marginLeft={2}
710 >
711 {renderChrome(entry)}
712 {entry.plan.tasks.length === 0 && entry.wrapUp.length === 0 && (
713 <Text
714 color='subtle'
715 italic
716 >
717 no task cards
718 </Text>
719 )}
720 {entry.tasks.map(boardTask => renderTask(entry, boardTask))}
721 {entry.wrapUp.map(line => (
722 <Text color='subtle'>{`${line.isChecked ? '✓' : '○'} ${clip(line.text, rowWidth - 4)}`}</Text>
723 ))}
724 </Box>
725 )}
726 </Box>
727 );
728 };
729
730 const section = (title: string, plans: BoardPlan[]) =>
731 plans.length === 0 ? null : (
732 <Box
733 flexDirection='column'
734 borderStyle='round'
735 borderColor='suggestion'
736 paddingX={1}
737 >
738 <Box gap={1}>
739 <Text
740 bold
741 color='suggestion'
742 >
743 {title}
744 </Text>
745 <Text color='subtle'>{plans.length}</Text>
746 </Box>
747 {plans.map(renderPlan)}
748 </Box>
749 );
750
751 const body = (
752 <Box
753 flexDirection='column'
754 marginY={1}
755 gap={1}
756 >
757 {snapshot.plans.length === 0 && (
758 <Text color='subtle'>No plans yet: /draft-plan writes them under .condux/plans.</Text>
759 )}
760 {Input !== undefined && snapshot.plans.length > 0 && (
761 <Input
762 key='filter'
763 label='Filter'
764 placeholder='plan title'
765 value={filterText}
766 onInput={value => setFilter($, value)}
767 onSubmit={value => setFilter($, value)}
768 />
769 )}
770 {shown.length === 0 && snapshot.plans.length > 0 && <Text color='subtle'>No plan matches the filter.</Text>}
771 {section('ACTIVE', groups.active)}
772 {section('COMPLETE', groups.complete)}
773 {groups.olderComplete.length > 0 && (
774 <Text color='subtle'>{`+${groups.olderComplete.length} older finished plans · /condux all`}</Text>
775 )}
776 {section('STALE', groups.stale)}
777 {groups.olderStale.length > 0 && (
778 <Text color='subtle'>{`+${groups.olderStale.length} older stale plans · /condux all`}</Text>
779 )}
780 {section('NO LEDGER · state unknown', groups.noLedger)}
781 {groups.olderNoLedger.length > 0 && (
782 <Text color='subtle'>{`+${groups.olderNoLedger.length} older plans with no ledger · /condux all`}</Text>
783 )}
784 </Box>
785 );
786
787 const unknown = unledgered(snapshot.plans);
788
789 return frame(
790 `${snapshot.plans.length} ${snapshot.plans.length === 1 ? 'plan' : 'plans'} · ${groups.active.length} active${
791 unknown > 0 ? ` · ${unknown} unledgered` : ''
792 }`,
793 body,
794 `tab move · ⏎ expand · r refresh · updated ${updatedAt}`,
795 );
796 });
797};
798hooks/mod/kit/charts.ts 139 lines1// The desktop's vector charts, as plain SVG strings. Mid-tone colours, so one drawing reads in both themes.
2export const PALETTE = ['#3f8f5b', '#c8643c', '#2f6db5', '#b8860b', '#7b5ea7', '#2a9d8f', '#b5527a'];
3
4export type Share = { name: string; count: number; color: string };
5
6const escapeXml = (text: string): string =>
7 text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
8
9// Done leads in green; each section after it takes the next colour, so a long board does not repeat one until the
10// palette runs out.
11export const colorFor = (index: number): string => PALETTE[index % PALETTE.length] ?? '#888888';
12
13const GAP = 3;
14
15// Each share's span along a track of `length`, proportional to its count except that none is shorter than `minSpan`: a
16// round-capped segment is at least as wide as it is thick, so a thinner share would spill over its neighbours. What the
17// small ones are lent is taken from the large ones in proportion.
18export const allot = (counts: number[], length: number, minSpan: number): number[] => {
19 const total = counts.reduce((sum, count) => sum + count, 0);
20
21 if (total === 0) {
22 return counts.map(() => 0);
23 }
24
25 const raw = counts.map(count => (count / total) * length);
26 const isSmall = raw.map(span => span < minSpan);
27 const lent = isSmall.filter(Boolean).length * minSpan;
28 const largeTotal = raw.reduce((sum, span, index) => sum + (isSmall[index] ? 0 : span), 0);
29
30 if (largeTotal <= 0 || lent >= length) {
31 return raw;
32 }
33
34 const scale = (length - lent) / largeTotal;
35
36 return raw.map((span, index) => (isSmall[index] ? minSpan : span * scale));
37};
38
39export const ringSvg = (shares: Share[], doneCount: number): string => {
40 const total = shares.reduce((sum, share) => sum + share.count, 0);
41 const stroke = 10;
42 const radius = 38;
43 const circumference = 2 * Math.PI * radius;
44 const percent = total === 0 ? 0 : Math.round((doneCount / total) * 100);
45 const visibleShares = shares.filter(share => share.count > 0);
46
47 // The drawn arc is its `visible` length plus a round cap of half the stroke at each end, centred in the span it owns.
48 const spans = allot(visibleShares.map(share => share.count), circumference, stroke + GAP);
49
50 let offset = 0;
51
52 const arcs = visibleShares
53 .map((share, index) => {
54 const span = spans[index] ?? 0;
55 const title = `<title>${escapeXml(`${share.count} ${share.name.toLowerCase()}`)}</title>`;
56
57 // One segment is the whole ring: a gap would only be a notch.
58 if (visibleShares.length === 1) {
59 return `<circle cx="50" cy="50" r="${radius}" fill="none" stroke="${share.color}" stroke-width="${stroke}">${title}</circle>`;
60 }
61
62 const visible = Math.max(0.01, span - stroke - GAP);
63 const start = offset + (span - visible) / 2;
64 const arc = `<circle cx="50" cy="50" r="${radius}" fill="none" stroke="${share.color}" stroke-width="${stroke}" stroke-linecap="round" stroke-dasharray="${visible.toFixed(2)} ${(circumference - visible).toFixed(2)}" stroke-dashoffset="${(-start).toFixed(2)}" transform="rotate(-90 50 50)">${title}</circle>`;
65
66 offset += span;
67
68 return arc;
69 })
70 .join('');
71
72 return [
73 '<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">',
74 arcs,
75 `<text x="50" y="47" text-anchor="middle" dominant-baseline="central" font-size="20" font-weight="600" fill="#888888">${percent}%</text>`,
76 '<text x="50" y="65" text-anchor="middle" dominant-baseline="central" font-size="9" fill="#888888">done</text>',
77 '</svg>',
78 ].join('');
79};
80
81// Rounded progress segments: each is a line with round caps, so a share narrower than the bar is tall stays a dot. Drawn
82// large and sized by its own width/height: an SVG with a viewBox alone has no intrinsic size, and the desktop gives it a
83// tall default box instead.
84export const BAR_HEIGHT = 22;
85
86export const barSvg = (shares: Share[], width = 1200, height = BAR_HEIGHT): string => {
87 const total = shares.reduce((sum, share) => sum + share.count, 0);
88 const visibleShares = shares.filter(share => share.count > 0);
89 const middle = height / 2;
90 const open = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`;
91
92 if (total === 0) {
93 return `${open}</svg>`;
94 }
95
96 // Each share owns a span of the full width; a round cap overhangs its line by half the height, so the line runs between
97 // the caps' centres and the drawn segment fills the span minus the gap.
98 const gap = visibleShares.length > 1 ? height / 3 : 0;
99
100 const spans = allot(visibleShares.map(share => share.count), width, height + gap);
101
102 let cursor = 0;
103
104 const segments = visibleShares
105 .map((share, index) => {
106 const span = spans[index] ?? 0;
107 const from = cursor + gap / 2 + middle;
108 // A span holding only a cap's worth is a dot: the two ends meet.
109 const to = Math.max(from, cursor + span - gap / 2 - middle);
110
111 cursor += span;
112
113 return `<line x1="${from.toFixed(1)}" y1="${middle}" x2="${to.toFixed(1)}" y2="${middle}" stroke="${share.color}" stroke-width="${height}" stroke-linecap="round"><title>${escapeXml(`${share.count} ${share.name.toLowerCase()}`)}</title></line>`;
114 })
115 .join('');
116
117 return `${open}${segments}</svg>`;
118};
119
120export const sparklineSvg = (counts: number[], color: string): string => {
121 const barWidth = 10;
122 const gap = 4;
123 const height = 36;
124 const width = counts.length * (barWidth + gap) - gap;
125 const max = Math.max(1, ...counts);
126
127 const bars = counts
128 .map((count, index) => {
129 const barHeight = count === 0 ? 2 : Math.max(4, (count / max) * height);
130 const weeksAgo = counts.length - 1 - index;
131 const when = weeksAgo === 0 ? 'this week' : `${weeksAgo} ${weeksAgo === 1 ? 'week' : 'weeks'} ago`;
132
133 return `<rect x="${index * (barWidth + gap)}" y="${(height - barHeight).toFixed(1)}" width="${barWidth}" height="${barHeight.toFixed(1)}" rx="2" fill="${color}" fill-opacity="${count === 0 ? 0.25 : 1}"><title>${count} closed ${when}</title></rect>`;
134 })
135 .join('');
136
137 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${bars}</svg>`;
138};
139hooks/mod/kit/locate.ts 25 lines1import { type EngineInterface } from 'claude-code';
2
3import { ancestors, joinPath } from './paths';
4
5export type WorkingState = { root: string; dir: string };
6
7// The working-state directory of the repository the session is in: `<git-root>/.condux`. The walk stops at the first
8// `.git`, so a nested repository never adopts its parent's plans. A function declaration, not an arrow: this is called
9// from another file with `$`, and past an import the validator follows `$` only into declarations.
10export async function locateWorkingState($: EngineInterface, cwd: string): Promise<WorkingState | null> {
11 for (const dir of ancestors(cwd)) {
12 const candidate = joinPath(dir, '.condux');
13
14 if (await $.fs.exists(candidate)) {
15 return { root: dir, dir: candidate };
16 }
17
18 if (await $.fs.exists(joinPath(dir, '.git'))) {
19 return null;
20 }
21 }
22
23 return null;
24}
25hooks/mod/kit/paths.ts 58 lines1// Path helpers for a module that runs with no Node: plain string work over POSIX and Windows paths. These are docket's
2// (skills/record/pane/hooks/docket.ts), copied because plugins install independently and share no code.
3const DRIVE_RE = /^[A-Za-z]:$/;
4
5const separatorOf = (path: string): string => (path.includes('\\') && !path.includes('/') ? '\\' : '/');
6
7export const ancestors = (dir: string): string[] => {
8 const sep = separatorOf(dir);
9 const parts = dir.split(/[\\/]/).filter(part => part !== '');
10 const isUnc = dir.startsWith('\\\\');
11 const isAbsolute = dir.startsWith('/') || dir.startsWith('\\');
12 const hasDrive = parts.length > 0 && DRIVE_RE.test(parts[0] ?? '');
13 const result: string[] = [];
14
15 let prefix = '';
16
17 if (isUnc) {
18 prefix = '\\\\';
19 } else if (isAbsolute) {
20 prefix = sep;
21 }
22
23 for (let count = parts.length; count >= 1; count -= 1) {
24 // A bare drive (`C:`) is not a directory; the root entry below stands in for it.
25 if (hasDrive && count === 1) {
26 break;
27 }
28
29 result.push(prefix + parts.slice(0, count).join(sep));
30 }
31
32 if (hasDrive) {
33 result.push(`${parts[0]}${sep}`);
34 } else if (isAbsolute && !isUnc) {
35 result.push(sep);
36 }
37
38 return result;
39};
40
41export const joinPath = (dir: string, ...parts: string[]): string => {
42 const sep = separatorOf(dir);
43 const isRoot = dir === '/' || dir === '\\';
44 let result = isRoot ? '' : dir.replace(/[\\/]+$/, '');
45
46 for (const part of parts) {
47 const clean = part.replace(/^[\\/]+/, '').replace(/[\\/]+$/, '');
48
49 if (clean === '') {
50 continue;
51 }
52
53 result = `${result}${sep}${clean}`;
54 }
55
56 return result === '' ? dir : result;
57};
58hooks/mod/board/badges.ts 19 lines1import { type BoardPlan } from '../types';
2
3// Facts read best-effort out of the plan and its ledger. A badge with nothing to read is absent, never guessed.
4export const badges = (plan: BoardPlan): string[] => {
5 const lines = plan.ledger?.lines.map(line => line.text) ?? [];
6 const pr = lines.map(text => /\bPR\s*#(\d+)/i.exec(text)?.[1]).find(Boolean);
7 const branch = lines.map(text => /\bbranch\s+`?([\w./-]+)`?/i.exec(text)?.[1]).find(Boolean);
8 const commits = lines.filter(text => /\bcommit(?:ted)?\s+`?[0-9a-f]{7,}/i.test(text)).length;
9 const base = plan.ledger?.baseSha;
10
11 return [
12 plan.plan.tier === null ? null : `${plan.plan.tier} tier`,
13 branch ?? null,
14 pr === undefined ? null : `PR #${pr}`,
15 base ? `BASE ${base.slice(0, 7)}` : null,
16 commits === 0 ? null : `${commits} ${commits === 1 ? 'commit' : 'commits'}`,
17 ].filter((badge): badge is string => badge !== null);
18};
19hooks/mod/board/graph.ts 185 lines1import { type BoardPlan, type TaskState } from '../types';
2
3// Mid-tone, so one drawing reads in both themes (the same family as the shared charts).
4export const STATE_HEX: Record<TaskState, string> = {
5 done: '#3f8f5b',
6 'next-ready': '#c8643c',
7 ready: '#2f6db5',
8 blocked: '#8a8a85',
9};
10
11const CYCLE_HEX = '#c0392b';
12const NODE_W = 30;
13const NODE_H = 22;
14const GAP_X = 20;
15const GAP_Y = 12;
16const PAD = 4;
17
18const escapeXml = (text: string): string =>
19 text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
20
21// A task's column: one past its deepest dependency. A dependency loop is cut where it is re-entered, so the layout
22// always ends.
23const layersOf = (plan: BoardPlan): Map<number, number> => {
24 const byNumber = new Map(plan.tasks.map(entry => [entry.task.number, entry]));
25 const layers = new Map<number, number>();
26 const visiting = new Set<number>();
27
28 const depth = (number: number): number => {
29 const known = layers.get(number);
30
31 if (known !== undefined) {
32 return known;
33 }
34
35 if (visiting.has(number)) {
36 return 0;
37 }
38
39 visiting.add(number);
40
41 const deps = byNumber.get(number)?.task.dependsOn ?? [];
42 const layer = deps.reduce((deepest, dep) => Math.max(deepest, depth(dep) + 1), 0);
43
44 visiting.delete(number);
45 layers.set(number, layer);
46
47 return layer;
48 };
49
50 plan.tasks.forEach(entry => depth(entry.task.number));
51
52 return layers;
53};
54
55// Rows within each column, ordered by the average row of a task's neighbours (dependencies on the way in, dependants on
56// the way back), so edges cross less. Two sweeps each way settle a plan of this size.
57const orderRows = (plan: BoardPlan, layers: Map<number, number>): Map<number, number> => {
58 const columns = new Map<number, number[]>();
59 const dependants = new Map<number, number[]>();
60 const rowOf = new Map<number, number>();
61
62 for (const entry of plan.tasks) {
63 const layer = layers.get(entry.task.number) ?? 0;
64
65 columns.set(layer, [...(columns.get(layer) ?? []), entry.task.number]);
66
67 for (const dep of entry.task.dependsOn) {
68 dependants.set(dep, [...(dependants.get(dep) ?? []), entry.task.number]);
69 }
70 }
71
72 const settle = (): void => columns.forEach(column => column.forEach((number, row) => rowOf.set(number, row)));
73
74 const sweep = (order: number[], neighbours: (number: number) => number[]): void => {
75 for (const layer of order) {
76 const column = columns.get(layer) ?? [];
77 const keyed = column.map(number => {
78 const rows = neighbours(number).map(other => rowOf.get(other) ?? 0);
79 const key = rows.length === 0 ? (rowOf.get(number) ?? 0) : rows.reduce((sum, row) => sum + row, 0) / rows.length;
80
81 return { number, key };
82 });
83
84 columns.set(layer, keyed.sort((a, b) => a.key - b.key).map(item => item.number));
85 settle();
86 }
87 };
88
89 const byDepth = [...columns.keys()].sort((a, b) => a - b);
90 const byEntry = new Map(plan.tasks.map(entry => [entry.task.number, entry]));
91
92 settle();
93
94 for (let round = 0; round < 2; round += 1) {
95 sweep(byDepth, number => byEntry.get(number)?.task.dependsOn ?? []);
96 sweep([...byDepth].reverse(), number => dependants.get(number) ?? []);
97 }
98
99 return rowOf;
100};
101
102// Only a plan whose tasks depend on each other has a graph worth drawing.
103export const hasGraph = (plan: BoardPlan): boolean =>
104 plan.tasks.length > 1 && plan.tasks.some(entry => entry.task.dependsOn.length > 0);
105
106// Left to right, one column per dependency depth; an edge runs from a dependency's right side to its dependant's left,
107// green once the dependency is done. Drawn at its natural size and scaled into `maxWidth` by the viewBox, so a long
108// chain stays inside the pane.
109export const graphSvg = (plan: BoardPlan, maxWidth = 640): { svg: string; width: number; height: number } => {
110 const layers = layersOf(plan);
111 const isUnknown = plan.status === 'no-ledger';
112 const rowOf = orderRows(plan, layers);
113 const rowsInLayer = new Map<number, number>();
114 const spots = new Map<number, { x: number; y: number }>();
115
116 for (const entry of plan.tasks) {
117 const layer = layers.get(entry.task.number) ?? 0;
118 const row = rowOf.get(entry.task.number) ?? 0;
119
120 rowsInLayer.set(layer, Math.max(rowsInLayer.get(layer) ?? 0, row + 1));
121 spots.set(entry.task.number, { x: PAD + layer * (NODE_W + GAP_X), y: PAD + row * (NODE_H + GAP_Y) });
122 }
123
124 const columns = Math.max(1, ...[...rowsInLayer.keys()].map(layer => layer + 1));
125 const rows = Math.max(1, ...rowsInLayer.values());
126 const naturalWidth = PAD * 2 + columns * NODE_W + (columns - 1) * GAP_X;
127 const naturalHeight = PAD * 2 + rows * NODE_H + (rows - 1) * GAP_Y;
128 const scale = Math.min(1, maxWidth / naturalWidth);
129 const width = Math.round(naturalWidth * scale);
130 const height = Math.round(naturalHeight * scale);
131 const colorOf = (entry: BoardPlan['tasks'][number]): string => {
132 if (entry.inCycle) {
133 return CYCLE_HEX;
134 }
135
136 return isUnknown ? STATE_HEX.blocked : STATE_HEX[entry.state];
137 };
138
139 const edges = plan.tasks.flatMap(entry =>
140 entry.task.dependsOn.flatMap(dep => {
141 const from = spots.get(dep);
142 const to = spots.get(entry.task.number);
143 const source = plan.tasks.find(candidate => candidate.task.number === dep);
144
145 if (!from || !to || !source) {
146 return [];
147 }
148
149 const x1 = from.x + NODE_W;
150 const y1 = from.y + NODE_H / 2;
151 const x2 = to.x;
152 const y2 = to.y + NODE_H / 2;
153 const bend = Math.max(6, (x2 - x1) / 2);
154 const isDoneEdge = source.state === 'done' && !isUnknown;
155 const idle = isDoneEdge ? STATE_HEX.done : STATE_HEX.blocked;
156 const stroke = entry.inCycle ? CYCLE_HEX : idle;
157
158 return [
159 `<path d="M${x1} ${y1} C${x1 + bend} ${y1} ${x2 - bend} ${y2} ${x2} ${y2}" fill="none" stroke="${stroke}" stroke-width="1.5" stroke-opacity="0.8"/>`,
160 ];
161 }),
162 );
163
164 const nodes = plan.tasks.map(entry => {
165 const spot = spots.get(entry.task.number) ?? { x: PAD, y: PAD };
166 const title = escapeXml(`Task ${entry.task.number}: ${entry.task.name}`);
167
168 return [
169 `<g><title>${title}</title>`,
170 `<rect x="${spot.x}" y="${spot.y}" width="${NODE_W}" height="${NODE_H}" rx="${NODE_H / 2}" fill="${colorOf(entry)}"/>`,
171 `<text x="${spot.x + NODE_W / 2}" y="${spot.y + NODE_H / 2}" text-anchor="middle" dominant-baseline="central" font-size="11" font-weight="600" fill="#ffffff">${entry.task.number}</text>`,
172 '</g>',
173 ].join('');
174 });
175
176 const svg = [
177 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${naturalWidth} ${naturalHeight}">`,
178 ...edges,
179 ...nodes,
180 '</svg>',
181 ].join('');
182
183 return { svg, width, height };
184};
185hooks/mod/board/parse-ledger.ts 42 lines1import { type Ledger, type LedgerLine } from '../types';
2
3const PLAN_RE = /^Plan:\s*(.+?)\s*$/;
4// The header is prose an agent wrote: the path may sit in backticks or quotes and be followed by commentary ("(signed off
5// 2026-09-17; do not edit). Mode chosen at CP-1: ..."), so the plan is the first Markdown file path in the line.
6const PLAN_PATH_RE = /([^\s`'"()<>[\]]+\.md)\b/i;
7const BASE_RE = /^BASE:\s*(\S+)/;
8const CHECKBOX_RE = /^[-*]\s+\[([ xX])\]\s*(.*?)\s*$/;
9const TASK_RE = /^Task\s+(\d+)\b/i;
10
11// The ledger is written by an agent while it works, so it is read leniently: a line that is not a checkbox is prose and
12// is skipped, never an error.
13export const parseLedger = (text: string, file: string, mtimeMs: number): Ledger => {
14 let planFile: string | null = null;
15 let baseSha: string | null = null;
16
17 const lines: LedgerLine[] = [];
18
19 for (const raw of text.split('\n')) {
20 const line = raw.replace(/\r$/, '');
21 const plan = line.match(PLAN_RE)?.[1];
22 const base = line.match(BASE_RE)?.[1];
23 const box = line.match(CHECKBOX_RE);
24
25 if (plan !== undefined && planFile === null) {
26 // The header names a path from the repo root; only the file name links it to a plan.
27 const path = plan.match(PLAN_PATH_RE)?.[1] ?? plan.replace(/[`'"]/g, '');
28
29 planFile = path.split(/[\\/]/).pop() || null;
30 } else if (base !== undefined && baseSha === null) {
31 baseSha = base;
32 } else if (box) {
33 const body = box[2] ?? '';
34 const task = body.match(TASK_RE)?.[1];
35
36 lines.push({ taskNumber: task === undefined ? null : Number(task), isChecked: box[1] !== ' ', text: body });
37 }
38 }
39
40 return { file, planFile, baseSha, lines, mtimeMs };
41};
42hooks/mod/board/parse-plan.ts 152 lines1import { type Plan, type PlanTask } from '../types';
2
3const FENCE_RE = /^\s*(?:```|~~~)/;
4const HEADING_RE = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
5const TASK_HEADING_RE = /^(#{2,3})\s+Task\s+(\d+)\s*[:.—–-]\s*(.+?)\s*$/;
6const DEPENDENCIES_RE = /^\*{0,2}Dependencies:?\*{0,2}:?\s*(.*)$/i;
7const DATED_FILE_RE = /^(\d{4}-\d{2}-\d{2})-(.+?)(?:\.md)?$/;
8const TIER_RE = /\b(SMALL|MEDIUM|LARGE) tier\b/;
9const RANGE_RE = /\bTasks?\s+(\d+)\s*[-–—]\s*(\d+)\b/g;
10const LIST_RE = /\bTasks?\s+(\d+(?:\s*(?:,|and|&)\s*(?:Tasks?\s+)?\d+)*)/g;
11// How far down a plan a stated tier is still the plan's own, not a line quoted from elsewhere.
12const TIER_WINDOW = 15;
13const MAX_RANGE = 200;
14
15type Line = { text: string; isFenced: boolean };
16
17// Fence state is decided per line up front, so every later pass agrees on which headings are real.
18const markFences = (text: string): Line[] => {
19 let isOpen = false;
20
21 return text.split('\n').map(raw => {
22 const line = raw.replace(/\r$/, '');
23
24 if (FENCE_RE.test(line)) {
25 isOpen = !isOpen;
26
27 return { text: line, isFenced: true };
28 }
29
30 return { text: line, isFenced: isOpen };
31 });
32};
33
34// "Task 1 (defines X)" keeps its dependency and "None (independent of Tasks 1–3)" has none: strip the parentheticals
35// first so their contents never count, then read ranges, then plain mentions.
36export const parseDependencies = (raw: string, own: number, known: Set<number>): number[] => {
37 const stripped = raw.replace(/\([^)]*\)/g, '');
38
39 if (/^\s*none\b/i.test(stripped)) {
40 return [];
41 }
42
43 const numbers = new Set<number>();
44
45 for (const match of stripped.matchAll(RANGE_RE)) {
46 const from = Number(match[1]);
47 const to = Math.min(Number(match[2]), from + MAX_RANGE);
48
49 for (let n = from; n <= to; n += 1) {
50 numbers.add(n);
51 }
52 }
53
54 for (const match of stripped.replace(RANGE_RE, ' ').matchAll(LIST_RE)) {
55 for (const digits of match[1]?.match(/\d+/g) ?? []) {
56 numbers.add(Number(digits));
57 }
58 }
59
60 return [...numbers].filter(n => n !== own && known.has(n)).sort((a, b) => a - b);
61};
62
63export const parsePlan = (text: string, file: string): Plan => {
64 const lines = markFences(text);
65 const dated = file.match(DATED_FILE_RE);
66 const slug = dated ? (dated[2] ?? file) : file.replace(/\.md$/, '');
67
68 let title = slug;
69 let tier: Plan['tier'] = null;
70 let hasTitle = false;
71
72 lines.forEach((line, index) => {
73 if (line.isFenced) {
74 return;
75 }
76
77 const heading = line.text.match(HEADING_RE);
78
79 if (!hasTitle && heading?.[1] === '#') {
80 title = (heading[2] ?? slug).replace(/^Plan:\s*/i, '').trim() || slug;
81 hasTitle = true;
82 }
83
84 if (tier === null && index < TIER_WINDOW) {
85 tier = (line.text.match(TIER_RE)?.[1] as Plan['tier']) ?? null;
86 }
87 });
88
89 // Card boundaries: a task heading opens a card, and any real heading of its own level or higher closes it.
90 const cards: { number: number; name: string; start: number; level: number; end: number }[] = [];
91
92 lines.forEach((line, index) => {
93 if (line.isFenced) {
94 return;
95 }
96
97 const task = line.text.match(TASK_HEADING_RE);
98
99 if (task) {
100 cards.push({
101 number: Number(task[2]),
102 name: task[3] ?? '',
103 start: index + 1,
104 level: (task[1] ?? '').length,
105 end: lines.length,
106 });
107 }
108 });
109
110 cards.forEach(card => {
111 for (let index = card.start; index < lines.length; index += 1) {
112 const line = lines[index];
113 const level = line && !line.isFenced ? (line.text.match(HEADING_RE)?.[1]?.length ?? 0) : 0;
114
115 if (level > 0 && level <= card.level) {
116 card.end = index;
117
118 return;
119 }
120 }
121 });
122
123 const seen = new Set<number>();
124 const unique = cards.filter(card => {
125 if (seen.has(card.number)) {
126 return false;
127 }
128
129 seen.add(card.number);
130
131 return true;
132 });
133
134 const tasks: PlanTask[] = unique.map(card => {
135 const body = lines.slice(card.start, card.end);
136 const dependencies = body.find(line => !line.isFenced && DEPENDENCIES_RE.test(line.text));
137 const raw = dependencies?.text.match(DEPENDENCIES_RE)?.[1] ?? '';
138
139 return {
140 number: card.number,
141 name: card.name,
142 dependsOn: parseDependencies(raw, card.number, seen),
143 body: body
144 .map(line => line.text)
145 .join('\n')
146 .trim(),
147 };
148 });
149
150 return { file, slug, title, date: dated?.[1] ?? null, tier, tasks };
151};
152hooks/mod/board/state.ts 193 lines1import { type BoardPlan, type BoardTask, type Ledger, type LedgerLine, type Plan, type PlanStatus } from '../types';
2
3const MINUTE_MS = 60_000;
4const DAY_MS = 86_400_000;
5// A plan whose ledger has been untouched this long is abandoned, not running.
6export const STALE_DAYS = 14;
7const COMMIT_RE = /\bcommit(?:ted)?\b|\bPR\s*#?\d+/i;
8const OPEN: PlanStatus[] = ['not-started', 'in-progress', 'tasks-done'];
9
10const hasCommit = (wrapUp: LedgerLine[]): boolean => wrapUp.some(line => line.isChecked && COMMIT_RE.test(line.text));
11
12const dateMs = (date: string | null): number => (date === null ? 0 : (Date.parse(`${date}T00:00:00Z`) || 0));
13
14const ledgerSlug = (ledger: Ledger): string => ledger.file.replace(/\.md$/i, '');
15
16// The ledger names its plan; a ledger whose header is missing or stale is still found by its own file name, with or
17// without the plan's date prefix (a ledger is often named exactly like its plan).
18const linkLedger = (plan: Plan, ledgers: Ledger[]): Ledger | null =>
19 ledgers.find(ledger => ledger.planFile === plan.file) ??
20 ledgers.find(ledger => [plan.slug, plan.file.replace(/\.md$/i, '')].includes(ledgerSlug(ledger))) ??
21 null;
22
23// Whether a not-done task's unmet dependencies lead back to itself.
24const isInCycle = (start: number, unmet: Map<number, number[]>): boolean => {
25 const seen = new Set<number>();
26 const queue = [...(unmet.get(start) ?? [])];
27
28 while (queue.length > 0) {
29 const next = queue.shift();
30
31 if (next === undefined || seen.has(next)) {
32 continue;
33 }
34
35 if (next === start) {
36 return true;
37 }
38
39 seen.add(next);
40 queue.push(...(unmet.get(next) ?? []));
41 }
42
43 return false;
44};
45
46const deriveTasks = (plan: Plan, doneNumbers: Set<number>): BoardTask[] => {
47 const unmet = new Map(
48 plan.tasks.map(task => [task.number, task.dependsOn.filter(n => !doneNumbers.has(n))] as const),
49 );
50 const ready = plan.tasks.filter(
51 task => !doneNumbers.has(task.number) && (unmet.get(task.number) ?? []).length === 0,
52 );
53 const nextReady = ready.reduce<number | null>(
54 (lowest, task) => (lowest === null || task.number < lowest ? task.number : lowest),
55 null,
56 );
57
58 return plan.tasks.map(task => {
59 if (doneNumbers.has(task.number)) {
60 return { task, state: 'done', waitsOn: [], inCycle: false };
61 }
62
63 const waitsOn = unmet.get(task.number) ?? [];
64
65 if (waitsOn.length === 0) {
66 return { task, state: task.number === nextReady ? 'next-ready' : 'ready', waitsOn, inCycle: false };
67 }
68
69 return { task, state: 'blocked', waitsOn, inCycle: isInCycle(task.number, unmet) };
70 });
71};
72
73const deriveStatus = (
74 plan: Plan,
75 ledger: Ledger | null,
76 tasks: BoardTask[],
77 wrapUp: LedgerLine[],
78 nowMs: number,
79): PlanStatus => {
80 if (ledger === null) {
81 return 'no-ledger';
82 }
83
84 const doneCount = tasks.filter(t => t.state === 'done').length;
85 const isFinished = plan.tasks.length > 0 && doneCount === plan.tasks.length;
86
87 if (hasCommit(wrapUp) && (isFinished || plan.tasks.length === 0)) {
88 return 'complete';
89 }
90
91 if (nowMs - ledger.mtimeMs > STALE_DAYS * DAY_MS) {
92 return 'stale';
93 }
94
95 if (ledger.lines.length === 0) {
96 return 'not-started';
97 }
98
99 return isFinished ? 'tasks-done' : 'in-progress';
100};
101
102const orphanPlan = (ledger: Ledger): Plan => ({
103 file: ledger.planFile ?? ledger.file,
104 slug: ledgerSlug(ledger),
105 title: ledgerSlug(ledger),
106 date: null,
107 tier: null,
108 tasks: [],
109});
110
111const RANK: Record<PlanStatus, number> = {
112 'not-started': 0,
113 'in-progress': 0,
114 'tasks-done': 0,
115 complete: 1,
116 stale: 2,
117 'no-ledger': 3,
118};
119
120const recency = (entry: BoardPlan): number => entry.ledger?.mtimeMs ?? dateMs(entry.plan.date);
121
122const build = (plan: Plan, ledger: Ledger | null, nowMs: number, isOrphan: boolean): BoardPlan => {
123 const doneNumbers = new Set(
124 (ledger?.lines ?? []).flatMap(line => (line.isChecked && line.taskNumber !== null ? [line.taskNumber] : [])),
125 );
126 const tasks = deriveTasks(plan, doneNumbers);
127 const wrapUp = (ledger?.lines ?? []).filter(line => line.taskNumber === null);
128 const status = deriveStatus(plan, ledger, tasks, wrapUp, nowMs);
129 const isUnfinished = OPEN.includes(status);
130
131 return {
132 plan,
133 ledger,
134 status,
135 tasks,
136 wrapUp,
137 doneCount: tasks.filter(t => t.state === 'done').length,
138 idleMinutes:
139 ledger !== null && isUnfinished ? Math.max(0, Math.floor((nowMs - ledger.mtimeMs) / MINUTE_MS)) : null,
140 isOrphan,
141 };
142};
143
144// Every board rule lives here as a pure function of parsed data and a clock, so it needs no surface to be tested.
145export const deriveBoard = (plans: Plan[], ledgers: Ledger[], nowMs: number): BoardPlan[] => {
146 const linked = new Set<Ledger>();
147
148 const fromPlans = plans.map(plan => {
149 const ledger = linkLedger(plan, ledgers);
150
151 if (ledger !== null) {
152 linked.add(ledger);
153 }
154
155 return build(plan, ledger, nowMs, false);
156 });
157
158 const orphans = ledgers
159 .filter(ledger => !linked.has(ledger))
160 .map(ledger => build(orphanPlan(ledger), ledger, nowMs, true));
161
162 return [...fromPlans, ...orphans].sort((a, b) => RANK[a.status] - RANK[b.status] || recency(b) - recency(a));
163};
164
165// One line for the always-visible status entry: the open plan whose ledger changed most recently, or nothing.
166export const statusText = (board: BoardPlan[]): string | null => {
167 const open = board.filter(entry => entry.ledger !== null && OPEN.includes(entry.status));
168 const latest = open.reduce<BoardPlan | null>(
169 (best, entry) => (best === null || recency(entry) > recency(best) ? entry : best),
170 null,
171 );
172
173 if (latest === null) {
174 return null;
175 }
176
177 const total = latest.plan.tasks.length;
178 const next = latest.tasks.find(t => t.state === 'next-ready');
179 const parts = [`plan: ${latest.plan.slug}`];
180
181 if (total > 0) {
182 parts.push(`${latest.doneCount}/${total}`);
183 }
184
185 if (latest.status === 'tasks-done') {
186 parts.push('wrapping up');
187 } else if (next) {
188 parts.push(`next: Task ${next.task.number}`);
189 }
190
191 return parts.join(' · ');
192};
193hooks/mod/board/stepper.ts 41 lines1import { type BoardPlan } from '../types';
2
3export type Stage = { label: string; isDone: boolean; isGhost: boolean };
4
5// The wrap-up the workflow runs after the tasks. A ledger names these loosely, so each is matched by a stem.
6const GHOSTS: { label: string; match: RegExp }[] = [
7 { label: 'review', match: /review/i },
8 { label: 'preflight', match: /preflight|finalize/i },
9 { label: 'verify', match: /verif/i },
10 { label: 'commit', match: /commit/i },
11];
12
13// A plan with no ledger has no progress to step through: its tasks are already listed below.
14// One stage per task, then the ledger's wrap-up lines in file order (a recognised one under its short name, any other
15// verbatim), then a ghost for each of the four steps the ledger has not reached.
16export const stepperStages = (plan: BoardPlan): Stage[] => {
17 if (plan.status === 'no-ledger') {
18 return [];
19 }
20
21 const tasks = plan.tasks.map(entry => ({
22 label: String(entry.task.number),
23 isDone: entry.state === 'done',
24 isGhost: false,
25 }));
26
27 const wrap = plan.wrapUp.map(line => ({
28 label: GHOSTS.find(ghost => ghost.match.test(line.text))?.label ?? line.text,
29 isDone: line.isChecked,
30 isGhost: false,
31 }));
32
33 const ghosts = GHOSTS.filter(ghost => !plan.wrapUp.some(line => ghost.match.test(line.text))).map(ghost => ({
34 label: ghost.label,
35 isDone: false,
36 isGhost: true,
37 }));
38
39 return [...tasks, ...wrap, ...ghosts];
40};
41hooks/mod/board/text.ts 205 lines1import { type BoardPlan, type BoardSnapshot, type PlanStatus, type TaskState } from '../types';
2
3// How many plans each finished, stale and no-ledger group keeps in view; older ones are reached with `/condux all`.
4export const MAX_FINISHED = 5;
5
6export const glyphFor = (state: TaskState): string => {
7 if (state === 'done') {
8 return '✓';
9 }
10
11 if (state === 'next-ready') {
12 return '▶';
13 }
14
15 return state === 'ready' ? '○' : '⏸';
16};
17
18const LABELS: Record<PlanStatus, string> = {
19 'no-ledger': 'no ledger, state unknown',
20 'not-started': 'not started',
21 'in-progress': 'in progress',
22 'tasks-done': 'tasks done, wrapping up',
23 complete: 'complete',
24 stale: 'stale',
25};
26
27export const statusLabel = (status: PlanStatus): string => LABELS[status];
28
29export const isActive = (entry: BoardPlan): boolean =>
30 entry.status === 'not-started' || entry.status === 'in-progress' || entry.status === 'tasks-done';
31
32export type BoardGroups = {
33 active: BoardPlan[];
34 complete: BoardPlan[];
35 // Finished plans past the window; reachable only through `/condux all`.
36 olderComplete: BoardPlan[];
37 stale: BoardPlan[];
38 olderStale: BoardPlan[];
39 noLedger: BoardPlan[];
40 olderNoLedger: BoardPlan[];
41};
42
43// The board arrives sorted (active, complete, stale, no-ledger, newest first); this only cuts it into the groups
44// the pane and the text both draw. Each of the three old groups keeps its newest few in view, the rest counted: a
45// repository's years of unledgered plans would otherwise bury the one running. `isCapped` false (a filter is typed)
46// shows every match, since a person searching is asking for the old ones.
47export const groupPlans = (board: BoardPlan[], isCapped = true): BoardGroups => {
48 const room = isCapped ? MAX_FINISHED : board.length;
49 const finished = board.filter(entry => entry.status === 'complete');
50 const stale = board.filter(entry => entry.status === 'stale');
51 const noLedger = board.filter(entry => entry.status === 'no-ledger');
52
53 return {
54 active: board.filter(isActive),
55 complete: finished.slice(0, room),
56 olderComplete: finished.slice(room),
57 stale: stale.slice(0, room),
58 olderStale: stale.slice(room),
59 noLedger: noLedger.slice(0, room),
60 olderNoLedger: noLedger.slice(room),
61 };
62};
63
64// How many plans have no ledger at all: the pile that never moves on its own.
65export const unledgered = (board: BoardPlan[]): number => board.filter(entry => entry.status === 'no-ledger').length;
66
67// A plan with no ledger has no recorded progress, so its tasks get a neutral mark: claiming "ready" or "0 done" would
68// state something the board does not know.
69export const taskGlyph = (plan: BoardPlan, task: BoardPlan['tasks'][number]): string =>
70 plan.status === 'no-ledger' ? '·' : glyphFor(task.state);
71
72// What a blocked task waits on; empty for any other state, and for a plan with no ledger.
73export const waitLabel = (plan: BoardPlan, entry: BoardPlan['tasks'][number]): string => {
74 if (plan.status === 'no-ledger' || entry.state !== 'blocked') {
75 return '';
76 }
77
78 return entry.inCycle ? '⟲ cycle' : `waits on ${entry.waitsOn.join(', ')}`;
79};
80
81export const taskLine = (plan: BoardPlan, entry: BoardPlan['tasks'][number]): string => {
82 const wait = waitLabel(plan, entry);
83
84 return `${taskGlyph(plan, entry)} Task ${entry.task.number} ${entry.task.name}${wait === '' ? '' : ` ${wait}`}`;
85};
86
87// "3/6 done" for a plan with a ledger; just the task count for one without, which has no progress to report.
88export const planCount = (entry: BoardPlan): string => {
89 const total = entry.plan.tasks.length;
90
91 if (total === 0) {
92 return '';
93 }
94
95 return entry.status === 'no-ledger' ? `${total} tasks · ` : `${entry.doneCount}/${total} done · `;
96};
97
98const planLine = (entry: BoardPlan): string => `${entry.plan.title} (${planCount(entry)}${statusLabel(entry.status)})`;
99
100const describePlan = (entry: BoardPlan, isExpanded: boolean): string[] => {
101 const lines = [planLine(entry)];
102
103 if (isExpanded) {
104 lines.push(...entry.tasks.map(task => ` ${taskLine(entry, task)}`));
105 lines.push(...entry.wrapUp.map(line => ` ${line.isChecked ? '✓' : '○'} ${line.text}`));
106 }
107
108 return lines;
109};
110
111// The text a surface without the pane gets, and what `/condux all` prints. Active plans are drawn with their tasks;
112// everything else is one line, unless every plan was asked for.
113export const boardText = (snapshot: BoardSnapshot | null, isAll: boolean): string => {
114 if (snapshot === null) {
115 return 'The condux board is still loading.';
116 }
117
118 if (snapshot.kind === 'missing') {
119 return `No condux working state found in ${snapshot.searchedFrom} or above it.`;
120 }
121
122 if (snapshot.kind === 'unreadable') {
123 return `Could not read the condux plans: ${snapshot.reason}`;
124 }
125
126 const groups = groupPlans(snapshot.plans);
127 const blocks: string[] = [];
128
129 if (groups.active.length > 0) {
130 blocks.push(['ACTIVE', ...groups.active.flatMap(entry => describePlan(entry, true))].join('\n'));
131 }
132
133 const finished = isAll ? [...groups.complete, ...groups.olderComplete] : groups.complete;
134
135 if (finished.length > 0) {
136 blocks.push(['COMPLETE', ...finished.flatMap(entry => describePlan(entry, isAll))].join('\n'));
137 }
138
139 if (!isAll && groups.olderComplete.length > 0) {
140 blocks.push(`+${groups.olderComplete.length} older finished plans (run /condux all)`);
141 }
142
143 const stale = isAll ? [...groups.stale, ...groups.olderStale] : groups.stale;
144
145 if (stale.length > 0) {
146 blocks.push(['STALE', ...stale.flatMap(entry => describePlan(entry, isAll))].join('\n'));
147 }
148
149 if (!isAll && groups.olderStale.length > 0) {
150 blocks.push(`+${groups.olderStale.length} older stale plans (run /condux all)`);
151 }
152
153 const noLedger = isAll ? [...groups.noLedger, ...groups.olderNoLedger] : groups.noLedger;
154
155 if (noLedger.length > 0) {
156 const rows = noLedger.flatMap(entry => describePlan(entry, isAll));
157
158 blocks.push(['NO LEDGER (state unknown)', ...rows].join('\n'));
159 }
160
161 if (!isAll && groups.olderNoLedger.length > 0) {
162 blocks.push(`+${groups.olderNoLedger.length} older plans with no ledger (run /condux all)`);
163 }
164
165 return blocks.length === 0 ? 'No plans found.' : blocks.join('\n\n');
166};
167
168// Minutes as the shortest honest age: "5m", "3h", "2d".
169export const ageLabel = (minutes: number): string => {
170 if (minutes < 1) {
171 return '<1m';
172 }
173
174 if (minutes < 60) {
175 return `${Math.floor(minutes)}m`;
176 }
177
178 return minutes < 1440 ? `${Math.floor(minutes / 60)}h` : `${Math.floor(minutes / 1440)}d`;
179};
180
181// Whether an unattended agent looks alive: the ledger is the heartbeat. Only a plan with a ledger and unfinished work
182// has one.
183export const QUIET_MINUTES = 10;
184export const STALLED_MINUTES = 30;
185
186export type Activity = { label: string; tone: 'subtle' | 'warning' | 'error' };
187
188export const activity = (entry: BoardPlan): Activity | null => {
189 if (entry.idleMinutes === null) {
190 return null;
191 }
192
193 const age = ageLabel(entry.idleMinutes);
194
195 if (entry.idleMinutes >= STALLED_MINUTES) {
196 return { label: `stalled · ledger ${age} ago`, tone: 'error' };
197 }
198
199 if (entry.idleMinutes >= QUIET_MINUTES) {
200 return { label: `quiet · ledger ${age} ago`, tone: 'warning' };
201 }
202
203 return { label: `ledger updated ${age} ago`, tone: 'subtle' };
204};
205