SLOPSHOPPER

workflow

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

newpaneguardcommandstatustimer
★ 1v?MITupdated 2026-10-21jabworks/agentic-toolkit/skills/workflow
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workflow
│ ┃ 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

agentic-toolkit

CI condux plugin npm Trigger routing

Personal collection of agentic coding skills. Compatible with Claude Code, Codex, OpenCode, Cursor, Gemini CLI, and 40+ other tools via npx skills add.

Contents

Install

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.

Ask an agent to install it

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.

Compatibility with other skill libraries

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.

Claude Code — plugin marketplace

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

Codex — plugin manifests

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

OpenCode — condux plugin + merged-trigger skill variants

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 — merged-trigger skill variants

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.

Manual fallback

If running outside an agent environment, clone and copy to your tool's skills directory:

ToolSkills 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>/

Skills

<!-- catalog:begin readme-skills -->

SkillDescription
session-reportGenerate an explorable HTML report of session usage — tokens, cache, cost, subagents, skills
session-handoffPreserve and restore session context across agentic coding sessions
adapting-skillsStack and style priors for adapting skills to the jabworks conventions (opinionated — fork the priors for your own stack)
git-commitConventional-commit message from the diff, run safely — review before staging, no blind git add .
git-operationsDecision router for git — pick the right operation (undo/discard/stash/merge/push) with an undo path
git-worktreeDecision router for git worktrees — isolated workspaces, native-first, with cleanup and recovery paths
spec-browserCatalog and browse a specs/ tree — markdown index + folder-grouped doc site
releaseCut releases safely — machinery detection (changesets/toolkit/GitHub), dry-run first, rollback paths
coding-directiveHouse style for @jabworks repos — TypeScript, React, formatting, imports, naming, CSS (personal)
rememberContinuous memory for Codex (concord plugin) — captures each session from the rollout, ages it into tiers, recalls it
concord-doctorHealth check for the concord plugin — probes its Codex hooks and memory store on this host
condux-doctorHealth check for the condux plugin — runs the SessionStart routing hook and checks the agents shipped
docket-doctorHealth check for the docket plugin — probes the MCP registration on every host and the CLI fallback

<!-- catalog:end readme-skills -->

Condux

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.

Why condux

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:

  1. Proportional, not maximal — the process tier matches the task size.
  2. Lazy loading — downstream skills load only when their step is reached, keeping context (and cost) small.
  3. Soft gates, not hard walls — a gate can be skipped, but only consciously: the agent asks before bypassing, never silently.
  4. The user is in control — explicit instructions always beat the framework; "treat this as LARGE" or "skip the plan" is always valid.
  5. Implement yourself by default — subagents are the exception and need a concrete justification, never spawned to fill time.

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.

The flow

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:

TierLooks likeFlow
SMALLisolated change, 1–3 files, clear requirementsimplement → /preflight → /finalize
MEDIUMmulti-file, some design needed, known boundariesinline plan → implement → /preflight → /finalize
LARGEcross-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.

The skills
SkillDescription

<!-- catalog:begin readme-condux -->

SkillDescription
/workflowTier router — infer Small/Medium/Large, confirm with user, load only the skills the tier needs
/discoveryDesign 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
/blueprintDesign-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-planLean task-card plan (what/why/gotchas/deps), Markdown, LARGE tasks only
/test-first-developmentOpt-in tests-first — one upfront consent, then red-green-refactor; asks before editing existing specs
/subagent-executionNamed specialist agents for LARGE plans, only when justified, never to fill time
/subagent-deploymentFan out independent tasks across named agents in one message — ad-hoc, not a formal plan
/researchCited 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
/finalizeEnd-of-task quality gate — typecheck → lint → format → test, once, stop on first failure
/live-verificationRun 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-reviewOn-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-analysisRoot-cause-first bug investigation — enforces the 4-phase sequence before any fix
/technical-specScaffold and persist feature specs (decisions, API, fields, quirks) with a live HTML preview
/plan-reviewAnnotate 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 -->

Concord

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.

Docket

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.

Toolkit Ops

Repo-maintenance bundle (toolkit-ops) for working on this toolkit itself:

/plugin install toolkit-ops@jabworks-agentic-toolkit
SkillDescription

<!-- catalog:begin readme-toolkit-ops -->

SkillDescription
toolkit-orientationZero-context map of the repo — trees, bundles, manifest pairing, docs trust order
toolkit-foundryCreate and maintain skills — scaffold, register, sync, publish (formerly standalone plugin-foundry)
toolkit-change-controlClassify a change, pick the version bump, gate on the publish checklist
toolkit-skill-standardsFrontmatter budgets, trigger contract, progressive disclosure, collision scan
toolkit-debugging-playbookSymptom → discriminating command → root cause for skill/plugin problems
toolkit-failure-archaeologyGit-evidenced incident ledger — don't re-fight settled battles
toolkit-plugin-referenceVerified plugin.json / marketplace.json schema, Claude↔Codex divergences
toolkit-research-frontierOpen problems, assets, next steps, and the library-health campaign

<!-- catalog:end readme-toolkit-ops -->

Where artifacts land

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

Source 12 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