Reusable skills, plugins, and agent configs for AI-assisted development workflows.

Personal collection of agentic coding skills. Compatible with Claude Code, Codex, OpenCode, Cursor, Gemini CLI, and 40+ other tools via npx skills add.
npx skills add auto-detects the running agent and installs to the right directory:
npx skills add jabworks/agentic-toolkit
This works inside Claude Code, Codex, OpenCode, Cursor, and most other agentic tools — no flags needed.
The marketplace install is only half the job for condux, docket and concord — each finishes with a host-specific step no marketplace can perform. Every one of them ships an INSTALL.md written to be followed by an agent, so the shortest path is to hand the whole thing to the agent you already have open. Paste this:
Install plugins from the jabworks/agentic-toolkit marketplace on this machine.
1. Detect the host you are running in — Claude Code (~/.claude), Codex
($CODEX_HOME or ~/.codex), or OpenCode (~/.config/opencode).
2. Add the marketplace and install the plugins I named. If I did not name any,
ask me first — do not install all of them.
Claude Code /plugin marketplace add jabworks/agentic-toolkit
/plugin install <plugin>@jabworks-agentic-toolkit
Codex codex plugin marketplace add jabworks/agentic-toolkit
codex plugin add <plugin>@jabworks-agentic-toolkit
OpenCode add "@jabworks/condux" to the plugin array in opencode.json
3. Three plugins need a step the marketplace cannot do. Read the INSTALL.md
next to each one you installed, then run its installer:
condux <plugin-root>/install.mjs
docket <plugin-root>/skills/docket/record/server/install.sh
concord <plugin-root>/skills/concord/remember/references/install-codex-hook.sh
Show me the --dry-run plan before writing anything.
4. Verify rather than assume — run the doctor for each plugin and show me the
report as it printed:
/condux:condux-doctor /docket:docket-doctor /concord:concord-doctor
5. Report one row per host: what you changed, what was already correct, and
what any doctor flagged. If one warns that another installed skill library
conflicts, show me its removal command but do not remove anything.
Every step is safe to re-run, and each installer is read-modify-write on one key — none of them rewrites config another plugin owns.
condux conflicts with obra/superpowers — the library it reworks. Both register a SessionStart hook on the same matcher, each injecting a router claiming every dev task, and 11 of superpowers' 14 skills overlap 8 of condux's. Run one or the other. install.mjs and /condux:condux-doctor both detect it and print the removal command without running it; the overlap table and reasoning are in the condux README, and the registry driving the check is skills/condux-doctor/conflicts.json.
No other conflict is currently detected. The rest of the toolkit's skills — git, release, spec, session — occupy their own ground.
Alternatively, register as a plugin marketplace to install individual skills:
Via CLI:
claude plugin marketplace add jabworks/agentic-toolkit
claude plugin install session-handoff@jabworks-agentic-toolkit # claude plugin install session-handoff
claude plugin install toolkit-ops@jabworks-agentic-toolkit # claude plugin install toolkit-ops
Via Claude Code CLI:
/plugin marketplace add jabworks/agentic-toolkit
/plugin install session-handoff@jabworks-agentic-toolkit # /plugin install session-handoff
/plugin install toolkit-ops@jabworks-agentic-toolkit # /plugin install toolkit-ops
Via Claude Code CLI plugin menu:
/plugin
-> Marketplaces tab -> Add Marketplace -> jabworks/agentic-toolkit
-> Discover tab -> Search plugin name
This repo is also Codex plugin compatible:
Via CLI:
codex plugin marketplace add jabworks/agentic-toolkit
codex plugin add session-handoff@jabworks-agentic-toolkit
codex plugin add toolkit-ops@jabworks-agentic-toolkit
Via Codex CLI plugins menu:
/plugins
-> Add Marketplace -> jabworks/agentic-toolkit
-> Select jabworks/agentic-toolkit tab to add plugins
For the condux workflow, one plugin line is the whole install. The @jabworks/condux plugin bundles the 16 condux skills, injects the specialist agents (coder / explorer / planner / researcher), and wires an opt-in plan-review listener:
// opencode.json
{
"plugin": ["@jabworks/condux"]
}
The bundled skills register themselves on config.skills.paths, so no separate skills install is needed for condux.
For the rest of the toolkit skills (git-commit, session-handoff, release, spec-browser, coding-directive, …), install the OpenCode-facing variants. These fold each skill's when_to_use trigger conditions into its description — OpenCode surfaces only description when deciding which skill to load, so the plain skills/ tree would lose them:
npx skills add https://github.com/jabworks/agentic-toolkit/tree/main/dist/opencode/skills -a opencode
This copies skills into ~/.config/opencode/skills/. It also includes the condux skills, but the plugin already provides those — OpenCode dedupes by name, so installing both is harmless.
Cursor loads SKILL.md natively (since Cursor 2.4) but surfaces only description when deciding which skill to load — it never reads when_to_use. Install the Cursor-facing variants, which fold the trigger conditions in (same transform as the OpenCode tree):
npx skills add https://github.com/jabworks/agentic-toolkit/tree/main/dist/cursor/skills -a cursor
Run it in your project — skills land in .agents/skills/, which Cursor picks up per-project. Prefer project installs over --global: the CLI currently writes global skills to ~/.agents/skills/ against its own docs (vercel-labs/skills#421), and on a WSL setup that's the WSL home while Windows-side Cursor reads C:\Users\<you>\.agents\skills\ — the install silently vanishes.
Most plugins are also spec-conformant Agent Plugins (root plugin.json, flat skills/, docket MCP via spec mcp.json) — Cursor loads them directly. To try one without a marketplace: copy dist/plugins/<name> into ~/.cursor/plugins/local/<name> and fully restart Cursor. Plugin installs ship raw skill frontmatter, so the trigger-quality caveat below applies; the dist/cursor/skills/ install remains the best path for auto-triggering.
condux and concord are not Agent Plugins and won't load by that path. Codex picks its plugin loader by root-manifest presence, and the Agent Plugins loader has no hooks — shipping the manifest silently disabled every Codex hook those two have. Hooks are the point of both, so they ship without it. On Cursor use dist/cursor/skills/ for them, which is the better channel there anyway.
Verified end-to-end 2026-08-14 (Cursor on Windows, WSL remote):
| Status | |
|---|---|
Skills install, list, and invoke (/workflow etc.) | ✅ works — project scope, from dist/cursor/skills/ |
Plugins load as Agent Plugins (root plugin.json, flat skills/) | ✅ works — verified via ~/.cursor/plugins/local; excludes condux and concord since 2026-08-21, which ship no root manifest so their Codex hooks survive |
| Docket MCP server | ✅ works — auto-imported from an existing Claude Code plugin install, or manually via .cursor/mcp.json (see docket INSTALL.md) |
| Docket CLI fallback | ✅ works as-is (dependency-free) |
Condux /workflow routing | ⚠️ degrades — no SessionStart hook on Cursor, so routing relies on the skill descriptions instead of the injected routing rule |
| plan-review auto-capture, named agents (explorer/researcher/planner/coder/scout) | ❌ absent — Cursor has no ExitPlanMode/Stop hook or custom-subagent surface |
Bonus: if Claude Code is installed on the same machine, Cursor picks up its plugin ecosystem by itself — already-installed plugins (skills and MCP servers, docket included) just appear, and registered marketplaces surface in Cursor's Customize panel with one-click Add. Marketplace installs ship the raw SKILL.md, whose when_to_use Cursor ignores — fine for MCP-bearing plugins like docket, but for reliable skill triggering prefer the dist/cursor/skills/ install above. Cursor reads the marketplace clone on its own side of a WSL split, which can lag — update the marketplace in Claude Code if the listing looks stale.
If running outside an agent environment, clone and copy to your tool's skills directory:
| Tool | Skills directory |
|---|---|
| Claude Code | ~/.claude/skills/ |
| Codex | ~/.codex/skills/ |
| OpenCode | ~/.config/opencode/skills/ |
| Cursor | ~/.cursor/skills/ |
| Gemini CLI | ~/.gemini/skills/ |
| Most others | .agents/skills/ |
git clone https://github.com/jabworks/agentic-toolkit /tmp/agentic-toolkit
cp -r /tmp/agentic-toolkit/skills/<name> <skills-directory>/
<!-- catalog:begin readme-skills -->
| Skill | Description |
|---|---|
| session-report | Generate an explorable HTML report of session usage — tokens, cache, cost, subagents, skills |
| session-handoff | Preserve and restore session context across agentic coding sessions |
| adapting-skills | Stack and style priors for adapting skills to the jabworks conventions (opinionated — fork the priors for your own stack) |
| git-commit | Conventional-commit message from the diff, run safely — review before staging, no blind git add . |
| git-operations | Decision router for git — pick the right operation (undo/discard/stash/merge/push) with an undo path |
| git-worktree | Decision router for git worktrees — isolated workspaces, native-first, with cleanup and recovery paths |
| spec-browser | Catalog and browse a specs/ tree — markdown index + folder-grouped doc site |
| release | Cut releases safely — machinery detection (changesets/toolkit/GitHub), dry-run first, rollback paths |
| coding-directive | House style for @jabworks repos — TypeScript, React, formatting, imports, naming, CSS (personal) |
| remember | Continuous memory for Codex (concord plugin) — captures each session from the rollout, ages it into tiers, recalls it |
| concord-doctor | Health check for the concord plugin — probes its Codex hooks and memory store on this host |
| condux-doctor | Health check for the condux plugin — runs the SessionStart routing hook and checks the agents shipped |
| docket-doctor | Health check for the docket plugin — probes the MCP registration on every host and the CLI fallback |
<!-- catalog:end readme-skills -->
Lean agentic workflow plugin (condux). Install the full workflow bundle as a unit:
/plugin install condux@jabworks-agentic-toolkit
Inspired by obra/superpowers — condux reworks its skill-orchestration ideas around a lean, proportional-effort philosophy (tiered routing, lazy loading, soft gates) rather than always-on maximalism.
Installing is the whole setup: condux ships a SessionStart hook (Claude Code and Codex both) that puts its routing rule in context before your first prompt, so /workflow is reached as the entry point instead of being inferred from the skill catalog. That inference sits at roughly 80% on its own, and the misses are condux's own siblings winning the query — root-cause-analysis on a crash report, draft-plan on "write the plan" — which no description can fix without stealing their trigger space. The payload is ~390 tokens per session and lives in skills/workflow/hooks/routing.md; delete or edit that file to change or disable it.
Most workflow frameworks are maximal — every gate runs on every task, so a typo fix pays the same ceremony as a cross-cutting refactor. Condux inverts that with five operating rules:
The payoff: no over-engineered button changes, no under-planned refactors, predictable checkpoints where you drive every transition, and a small context footprint because only the skills a tier actually needs ever load.
Every dev task enters through /workflow. It infers a tier from the task (file count, design clarity, boundary crossings), confirms it with you, then runs the matching pipeline:
| Tier | Looks like | Flow |
|---|---|---|
| SMALL | isolated change, 1–3 files, clear requirements | implement → /preflight → /finalize |
| MEDIUM | multi-file, some design needed, known boundaries | inline plan → implement → /preflight → /finalize |
| LARGE | cross-cutting, unclear scope, multiple subsystems | /discovery → /draft-plan → implement → /preflight → /finalize |
flowchart TD
T([dev task]) --> W{"/workflow — infer tier, confirm"}
W -->|SMALL| S[implement]
W -->|MEDIUM| M["inline plan"] --> M2[implement]
W -->|LARGE| L["/discovery"] --> L2["/draft-plan"] --> L3[implement]
S --> F["/preflight → /finalize"]
M2 --> F
L3 --> F
Every tier ends with /preflight (an "am I actually done?" checklist) and /finalize (typecheck → lint → format → test, in order, once, stopping on the first failure) — quality checks run at the end, not scattered mid-implementation.
Checkpoints (MEDIUM/LARGE only): at each phase boundary the agent stops and presents a menu — after the plan (start implementing / tests-first / spawn agents / revise), after implementation (verify & finalize / code review / keep building), and after everything is green (review / commit / release / done). The agent never auto-advances past a checkpoint; SMALL runs linear with no menus.
Named agents: five specialists ship with the bundle — explorer (read-only codebase navigation), researcher (external API/library verification), planner (design → executable plan), coder (executes a provided plan), and scout (one angle of a /condux:research survey). Pipeline: explorer/researcher gather → planner plans → coder executes → finalize validates. The default is still to implement directly — agents are opt-in at checkpoints or justified by genuinely parallel work.
| Skill | Description |
|---|
<!-- catalog:begin readme-condux -->
| Skill | Description |
|---|---|
| /workflow | Tier router — infer Small/Medium/Large, confirm with user, load only the skills the tier needs |
| /discovery | Design gate — goal-round questions, alternatives, then a post-approach detail round (contracts, mappings, edge cases); the design doc builds section by section in a live browser preview, and sign-off signs it off plus, default-on, a structured tech spec |
| /blueprint | Design-time visual clarity — fires on the question a decision turns on, not on whether a feature has a UI; HTML wireframes and full renders in the surface-kit token language (two modes, one skeleton), inline-SVG diagrams (data models, flows, architecture, state machines); dependency-free |
| /draft-plan | Lean task-card plan (what/why/gotchas/deps), Markdown, LARGE tasks only |
| /test-first-development | Opt-in tests-first — one upfront consent, then red-green-refactor; asks before editing existing specs |
| /subagent-execution | Named specialist agents for LARGE plans, only when justified, never to fill time |
| /subagent-deployment | Fan out independent tasks across named agents in one message — ad-hoc, not a formal plan |
| /research | Cited multi-source research for dev questions — the main session leads, parallel scout agents write cited notes, and a decision-ready report lands in .condux/research/; sequential where no agents exist |
| /finalize | End-of-task quality gate — typecheck → lint → format → test, once, stop on first failure |
| /live-verification | Run the change and watch it work — drives the real UI or endpoint after finalize, light mode then dark, reports claim → evidence → verdict and names what it couldn't verify |
| /code-review | On-request diagnostic report (Critical/Important/Minor), never auto-triggers, never fixes |
| /preflight | "Am I actually done?" checklist before finalize — catches skipped steps and regressions, and drift-checks the implementation against the task's spec |
| /root-cause-analysis | Root-cause-first bug investigation — enforces the 4-phase sequence before any fix |
| /technical-spec | Scaffold and persist feature specs (decisions, API, fields, quirks) with a live HTML preview |
| /plan-review | Annotate a plan in a local browser with a categorized comment toolbar, then return approve/revise/deny to the agent — auto via a Claude Code ExitPlanMode hook or a Codex Stop hook, or manually. Self-contained, no egress |
<!-- catalog:end readme-condux -->
Continuous memory for Codex (concord) — the remember skill plus its doctor:
/plugin install concord@jabworks-agentic-toolkit
Three Codex hooks share one idempotent operation — sync the rollout forward from its recorded position — so capture is exactly-once regardless of hook order, and a hard-killed session is recovered at next start rather than lost. Memory ages through buffer, daily, recent, and archive tiers; explicitly pinned facts are never auto-compressed. references/install-codex-hook.sh wires the hooks; concord-doctor tells you whether they are still wired.
File-based project backlog plugin (docket) — two skills plus machinery:
/plugin install docket@jabworks-agentic-toolkit
Open items live in docket/DOCKET.md, closed items move to docket/archive/<year>.md with verification records, and ids are never reused — #N in a commit subject refers to the docket, not GitHub. The record skill captures and closes items; groom sweeps for stale and ghost work and recommends what to pick next. A dependency-free CLI (server/docket.mjs) keeps id allocation and archive moves byte-exact, a bundled MCP server exposes the same ops as tools (auto-registered on Claude Code; server/install.sh or the agent-followable server/INSTALL.md registers it for Codex/OpenCode), and docket.mjs browse renders a self-contained HTML board. Legacy root BACKLOG.md layouts are detected and work in place — migration is offered, never forced.
Repo-maintenance bundle (toolkit-ops) for working on this toolkit itself:
/plugin install toolkit-ops@jabworks-agentic-toolkit
| Skill | Description |
|---|
<!-- catalog:begin readme-toolkit-ops -->
| Skill | Description |
|---|---|
| toolkit-orientation | Zero-context map of the repo — trees, bundles, manifest pairing, docs trust order |
| toolkit-foundry | Create and maintain skills — scaffold, register, sync, publish (formerly standalone plugin-foundry) |
| toolkit-change-control | Classify a change, pick the version bump, gate on the publish checklist |
| toolkit-skill-standards | Frontmatter budgets, trigger contract, progressive disclosure, collision scan |
| toolkit-debugging-playbook | Symptom → discriminating command → root cause for skill/plugin problems |
| toolkit-failure-archaeology | Git-evidenced incident ledger — don't re-fight settled battles |
| toolkit-plugin-reference | Verified plugin.json / marketplace.json schema, Claude↔Codex divergences |
| toolkit-research-frontier | Open problems, assets, next steps, and the library-health campaign |
<!-- catalog:end readme-toolkit-ops -->
Skills that write files into your project follow one rule: durable output goes to a normal project path; working state goes to .<plugin-name>/ at the git root, gitignored.
<git-root>/
specs/ # durable — tech specs, committed, browsable via spec-browser
.condux/ # working state — designs/ plans/ progress/ scratch/
.session-handoff/ # working state — handoff docs
.session-report/ # working state — generated HTML usage reports
The spec is what you keep; the design and plan are scaffolding it was built from. Directories are named for the owning plugin, not the skill and not the artifac
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