SLOPSHOPPER

condux

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

newpaneguardcommandstatustimer
★ 1v2.41.0MITupdated 2026-10-21jabworks/agentic-toolkit/dist/plugins/condux
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · condux
│ ┃ condux ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ condux none found [ Refresh ] │ ⏺ Read(src/auth.ts) │ ┃ │ r refresh │ ⎿ Read 6 lines │ ┃ │ ──────────────────────────────────────── │ ⏺ Update(src/auth.ts) │ ┃ │ ──────────────────────────────────────── │ ⎿ Added 2 lines, removed 1 line │ ┃ │ ──────────────────────── │ ⏺ Bash(bun test) │ ┃ │ No condux working state found in │ ⎿ 3 pass, 1 fail │ ┃ │ /work/app or above it. │ │ ┃ ╰──────────────────────────────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /condux │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · condux
╭──────────────────────────────────────────────────────────╮ │ condux none found [ Refresh ] │ │ r refresh │ │ ──────────────────────────────────────────────────────── │ │ ──────────────────────────────────────────────── │ │ No condux working state found in /work/app or above it. │ ╰──────────────────────────────────────────────────────────╯
README

Condux

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.


Install

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.


Compatibility

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:

superpowerscondux
using-superpowersworkflow
brainstormingdiscovery
writing-plansdraft-plan
executing-plans · subagent-driven-developmentsubagent-execution
dispatching-parallel-agentssubagent-deployment
test-driven-developmenttest-first-development
systematic-debuggingroot-cause-analysis
verification-before-completionpreflight
requesting-code-review · receiving-code-reviewcode-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.

Other lineage

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.


The 16 skills

Start with /workflow — it is both the entry point and the operating manual.

SkillWhat it does
workflowRoutes any task to Small / Medium / Large and carries the operating rules. Every dev task starts here.
discoveryTurns 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.
blueprintDesign-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-planConverts a signed-off design into lean task cards — what, why, gotchas, dependencies.
technical-specDurable feature specs under specs/ — the PRD, decisions, contracts, quirks — with a live preview.
plan-reviewRenders a plan in a local browser for inline annotation, returns approve / revise / deny.
test-first-developmentTests before implementation. Opt-in, one upfront consent, never silently rewrites an existing spec.
subagent-executionExecutes a plan with named specialist agents; resumable from a progress ledger.
subagent-deploymentFans out 2+ genuinely independent tasks in a single message.
root-cause-analysisRoot-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.
finalizeThe single end-of-task quality gate: typecheck → lint → format → test, in order, once.
code-reviewDiagnostic report by severity. On request only; never auto-triggers, never fixes.
live-verificationDrives 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).
researchCited multi-source research for dev questions — parallel scout agents write cited notes, and a decision-ready report lands in .condux/research/.
condux-doctorIs 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.


The Claude Code mod

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).

/conduxOpens 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 itselfThe 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 lineOne 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/.

Artifacts

Two tiers, split by durability:

TierLocationCommitted
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/.


Security posture

Condux is designed to be auditable in a single sitting.

  • No runtime dependencies. Both bundled Node servers declare only {"type": "commonjs"} — there is no node_modules, no install step, and no third-party code in the execution path.
  • No network egress. The plan-review HTML template references no external origin — no CDN, font, script, or analytics. This is enforced by a test in the source repo, not just by convention.
  • Localhost only. Both servers bind 127.0.0.1 explicitly. Nothing is reachable off the machine.
  • Scoped file reads. The review server serves only documents it has itself enumerated; a request for anything outside that allowlist returns empty.
  • Hooks execute one thing. Each hook invokes 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).

Why the Codex Stop hook has a 96-hour timeout

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 and license

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.

Source 13 files
hooks/mod/register.tsx 26 lines
1import { 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};
26
hooks/mod/board/board.tsx 798 lines
1import { 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};
798
hooks/mod/kit/charts.ts 139 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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};
139
hooks/mod/kit/locate.ts 25 lines
1import { 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}
25
hooks/mod/kit/paths.ts 58 lines
1// 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};
58
hooks/mod/board/badges.ts 19 lines
1import { 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};
19
hooks/mod/board/graph.ts 185 lines
1import { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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};
185
hooks/mod/board/parse-ledger.ts 42 lines
1import { 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};
42
hooks/mod/board/parse-plan.ts 152 lines
1import { 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};
152
hooks/mod/board/state.ts 193 lines
1import { 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};
193
hooks/mod/board/stepper.ts 41 lines
1import { 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};
41
hooks/mod/board/text.ts 205 lines
1import { 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