Task lifecycle, PR automation, CI/RCA and retro skills for the ccxp-family workflow suite, plus general-purpose dev-tool skills (Cloudflare, docs, email…

Shared Claude Code skills for the ccxp-family workflow suite (task lifecycle, PR automation, CI/RCA, retros) plus general-purpose dev-tool skills — maintained in one place and used across every repo on a given machine. Split out of a private company repo so any team can adopt it with no company-specific dependency: every skill here is either fully generic or config-driven via environment variables with no baked-in default.
This repo is a Claude Code plugin and its own single-plugin marketplace, so /plugin can install all 51 skills in one step:
/plugin marketplace add Synx-Data-Labs/ccxp-skills
/plugin install ccxp-skills@ccxp-skills
Update later with /plugin update ccxp-skills, and remove with /plugin uninstall ccxp-skills. /plugin manages the clone for you.
The skills live at the repo root rather than under skills/, which the manifest declares via "skills": ".". That keeps each skill a sibling of the shared libs (_gh/, _session/, _taskid/, _ipm/, _docs/, _journal/), so the ../_gh/gh.sh-style references inside the skills resolve correctly under a plugin install.
_gh/git.sh (see Shared helpers below) wires its own repo-local git credentials on every invocation — nothing to do. statusLine.command (statusline-setup/SKILL.md) is the one exception: Claude Code has no per-plugin statusline mechanism, so it still needs a one-time, manual settings.json entry pointing at wherever /plugin install put this plugin — and if that install used a marketplace (not the directory source this repo's own dev checkout uses), the cache path is version-pinned (e.g. .../ccxp-skills/1.0.1/...) and needs re-pointing after /plugin update bumps it.
A one-time, machine-global setup that every consumer repo on the machine shares:
~/.claude/.env — shared config, loaded by skill scripts before any repo-local .env. chmod 600 it and keep it out of every repo (it's machine-global). Every variable below is optional with a documented graceful-degrade — nothing here is required just to use the suite:SLACK_WEBHOOK_URL / SLACK_WEBHOOK_URL_<NAME> — incoming webhooks for the slack skill's channels; <NAME> maps to --channel <name> (uppercased). See slack/SKILL.md. SLACK_WEBHOOK_URL_DEV is also what /ccxp, /retro and /ipm post through when the Slack MCP tool is unavailable (headless cron); a failed post is logged, never silent.CCXP_SLACK_CHANNEL — the one channel every daily-ccxp Slack use targets (e.g. #claude-notification): slack-check-reply standup polling, the ccxp standup, and retro Phase 6. Unset degrades to a one-line skip note (or an interactive prompt for retro). Optional per-use overrides, which win when set: SLACK_STANDUP_CHANNEL (standup / slack-check-reply) and RETRO_SLACK_CHANNEL (retro). The webhook fallback (slack-fallback.sh) still routes through SLACK_WEBHOOK_URL_DEV, which is bound to its own channel.SLACK_BOT_TOKEN / SLACK_AUTOMATION_ALERTS_CHANNEL_ID — bot-token Slack Web API access (_slack/webapi.sh, no MCP), currently used by ccxp's 1.2.2a RCA-in-thread reply (labrun-rca/slack-check-reply still call MCP Slack tools as of this writing — same underlying need, rewrite pending as a follow-up on T20261001-319589); _CHANNEL_ID is the raw channel ID (e.g. C0ALGAPRCA3), not a name — a bot token can't resolve names the way the retired MCP search could. Unset degrades to a one-line skip note (see ccxp/SKILL.md 1.2.2a, _slack/README.md). See T20261001-319589.PROJECT_OWNER / PROJECT_NUMBER — the org login + Project V2 number that _session/_lib.sh mirrors task status onto. Unset means the Project-board mirror is a silent no-op — the task file's own status: frontmatter stays the source of truth regardless.SESSION_TOKEN / PROJECT_PAT / GH_TOKEN — one of these must carry a token with org-level Projects: Read and write for the Project-board mirror above to work.ATTRIBUTION_CRONTAB_CCXP_PATHS — colon-separated clone path(s) treated as the automation/cron (ccxp) clones (vs. interactive) for the daily standup's attribution table. Unset means every location classifies as interactive — cosmetic only, never a functional failure. Deprecated: the old name ATTRIBUTION_CCXP_PATHS is still read as a fallback (the new name wins).METRICS_OWNER / METRICS_REPOS — the org + space-separated repo list retro Phase 4d reads dev/quality/metrics.jsonl scoreboards from. Unset/empty means Phase 4d reports "no scoreboard reachable" and skips.KNOWN_SIBLING_REPOS — comma-separated sibling repo names repo-conventions/scripts/lint_refs.py treats as "foreign" for bare reference qualification. Unset/empty is a no-op.gh CLI authenticated — gh auth login. Skills invoke gh via _gh/gh.sh, which auto-selects the account with access to the current repo (so multi-account machines work).pip install pyyaml. Used by repo-conventions' task-frontmatter lint and the sync-tasks / lint-tasks actions.Per-repo CI secret (set in each repo's GitHub settings, not locally):
Projects: Read and Write, for the sync-tasks action's project-pat input (name it whatever your org's secret-naming convention prefers — see actions/sync-tasks/README.md).| Skill | Purpose |
|---|---|
ccxp | Day-or-week orchestrator — daily standup, Monday IPM, focused work, Friday retro |
autopilot | Keep calling bare /drive back-to-back for a set duration (e.g. "for the next 6 hours") |
drive | Pick ONE task, drive it to done — recurse into blockers, never switch laterally |
todo | List open tasks, pick the next actionable, reorder/clean up the backlog |
claim | Claim/check/release a task outside the full /drive loop |
epic | status/refresh/drive a standing epic in dev/EPICS/ — drive runs its member tasks one at a time via /drive (user-invoked; merges PRs) |
stage | Ensure a task is present in dev/TODO/queue.md |
migrate-task | Move a task file (and its queue.md entry) from one repo's dev/TODO/ to another's |
top / bottom | Move a task (or several) to the front/back of the priority queue |
eta | Projected finish time for the current (or an explicit) task — elapsed vs. remaining estimation |
new-task | File a new task to the dev/TODO backlog |
support | Track a customer-reported GitHub issue as a staged support task (intake only, posts nothing) |
incept | Adversarial frontier-round interview of a task/plan before implementation; the /ipm step 3 pre-IPM design pass |
gcpr (alias land) | Commit & push uncommitted changes, open a PR, hand off to address-pr |
address-pr | Address PR review comments, verify CI + hard gate, merge or notify |
cleanup-branch | Post-merge branch cleanup — a specific branch, a PR-merge sweep, or a bulk prune |
retro | Weekly retrospective — metrics, analysis, action items |
memory-to-skill | Review the current clone's auto-memory, prune stale entries, codify recurring ones into a skill/doc/hook |
rca | Diagnose/classify/track root cause for a failed GitHub Actions run |
labrun-rca | RCA a failed run announced via Slack permalink, reply in the thread |
design-score | Deterministic 0–100 structural score gating a design doc's Phase 2 → Phase 3 |
quality-probe | Measure code quality on a task's touched files, record to dev/quality/metrics.jsonl |
journal-compact | Compact a month of dev/JOURNAL/ into a digest |
repo-conventions | CLAUDE.md/guidelines.md structure, dev/ TODO lifecycle, conventions lint, branch-policy switch, and setup — bring a repo fully online in one pass |
skill-conventions | Conventions for authoring a SKILL.md in this suite |
| Skill | Purpose |
|---|---|
slack | Send a Slack notification to a configured channel |
slack-check-reply | Check replies on escalation/standup Slack threads |
email-triage | Triage the Gmail inbox — summarize, surface the top action item |
1password-env-setup | Bootstrap a repo's .env via op inject, plus a skip-if-populated .envrc |
| Skill | Purpose |
|---|---|
verify-site | Local preview + automated test-plan checks for a site PR |
help-net | Triage traveler network issues on macOS (GFW, throttle, MTU, DNS, VPN) |
statusline-setup | Deploy/edit/test the Claude Code statusline script (also feeds the ccxp-hud mod, hud/README.md) |
md-to-pdf | Convert markdown to PDF with scaled images, CJK support |
proof-read | Sentence-level consistency check for markdown (term drift, undefined jargon, CN↔EN) |
translate-patent | Translate a patent DOCX from Chinese to English |
| Skill | Purpose |
|---|---|
cloudflare | Workers, Pages, storage, AI, networking, security, IaC — general Cloudflare development |
cloudflare-email-service | Transactional email sending + routing on Cloudflare |
cloudflare-one | Zero Trust / SASE — Access, Gateway, WARP, Tunnel, DLP, CASB |
cloudflare-one-migrations | Migration assessments from other VPN/SASE stacks to Cloudflare One |
agents-sdk | Build stateful AI agents on Workers (Agents SDK) |
durable-objects | Stateful coordination, RPC, SQLite storage, alarms on Durable Objects |
sandbox-sdk | Sandboxed code execution (interpreters, CI/CD, untrusted code) |
turnstile-spin | End-to-end Turnstile (CAPTCHA) setup in a project |
workers-best-practices | Review/author Workers code against production best practices |
wrangler | Cloudflare Workers CLI reference for deploy/dev/manage across all bindings |
web-perf | Core Web Vitals / performance audit via Chrome DevTools MCP |
autopilot, bottom, memory-to-skill, agent-pairing, cleanup-branch, epic and to-task are model-visible, but the plugin's hooks/skill-ask.sh PreToolUse hook (matcher Skill) makes Claude Code prompt you before the model may run them. Typed /skill is unaffected. Edit hooks/ask-skills.txt to change the list. Headless -p, dontAsk and dispatched sub-agents cannot answer the prompt, so the call is denied there. Every other skill is model-invocable with no prompt.
Canonical cross-repo docs at this repo's root (peers, not skills) — reference these from each consumer repo's dev/guidelines.md rather than copying them per-repo:
lifecycle.md — task lifecycle: status flow, ID format, blocking, creating/working/completing procedures.gotchas.md — hard-won operational gotchas.glossary.md — canonical definitions of coined terms & acronyms (looked up by /proof-read's undefined-jargon check).engineering-standards.md — the code/design quality bar (TDD, test-driven refactoring, shared-lib design, quality metrics); gated by design-score, measured by quality-probe.secrets-management.md — the generic half of a secrets-management policy (automation tiers, adding a new secret). Author your own account-structure and vault-map sections to match your org — this doc deliberately omits that org-specific half.Edits to <skill>/SKILL.md take effect immediately in every consumer repo — it's a real checkout, not a read-only cache. When ready, commit + push here; other machines pick up changes with git pull, or with /plugin update ccxp-skills where the repo is installed as a plugin.
.claude-plugin/plugin.json declares "skills": ".", so the plugin's skill root is the repo root rather than a skills/ subdirectory. That is deliberate: each skill sits beside the shared libs (_gh/, _session/, _taskid/, _ipm/, _docs/, _journal/, _queue/) and reaches them through ../_gh/gh.sh-style relative references. Moving the skills under skills/ would break every one of those and the symlink installer with them. .claude-plugin/marketplace.json makes the repo its own single-plugin marketplace, which is why /plugin marketplace add takes the repo slug directly.
<name>/SKILL.md at the repo root with proper frontmatter (name, description, argument-hint)git pull, or /plugin update ccxp-skillsNo manifest edit is needed — "skills": "." means any root directory containing a SKILL.md is picked up automatically, by both install paths. The frontmatter name is what the skill is invoked as, so keep it equal to the directory name. Verify with claude plugin validate ., and claude plugin details ccxp-skills once installed to see the component inventory and its token cost.
If a skill needs helper scripts (bash, python, etc.), put them under <skill>/scripts/ and reference them from SKILL.md by a path relative to this skill's own directory (the harness prints that directory as "Base directory for this skill" when the skill loads — resolve against it, not against whatever the consumer repo's cwd happens to be):
bash ../<name>/scripts/<script>.sh <args>
There is no absolute path that works under every install method — a plugin-cache install's path is version-pinned and moves on every /plugin update, and the script runs against an arbitrary consumer repo's cwd, not this repo's. Scripts must:
bash ../_gh/gh.sh repo view --json nameWithOwner --jq .nameWithOwner — never hardcode owner/repo.dirname "${BASH_SOURCE[0]}", never a hardcoded path — this is what makes the script's own invocations robust regardless of caller cwd (see _taskid/in-this-repo.sh for the pattern).REPO env var override for explicit targeting.SLACK_WEBHOOK_URL) from ~/.claude/.env first, then $(pwd)/.env.dev/EPICS/E*.md / dev/ROADMAP.md format (local to each repo)Every repo owns its own dev/ROADMAP.md and dev/EPICS.md — there is no shared hub repo and no ROADMAP_TARGET_REPO config (T20261005-639913 retired that global variable; each file lives at the repo's own toplevel, read and edited as plain local files). /ipm step 5b updates dev/ROADMAP.md in place (creating it from ipm/templates/ROADMAP.md on first use); dev/EPICS.md is human-authored, proposed/edited by /retro Phase 2b.
ccxp/scripts/epic-status.sh (T20260911-347027) reads the local dev/EPICS.md to roll up epic-level progress into the daily /ccxp standup. Per epic:
### E1 — <title>
Goal: <one line — the standing goal this epic exists for>
Done when: <one line — the concrete completion condition>
Deadline: YYYY-MM-DD # optional
- [T20260101-000001](../TODO/T20260101-000001-slug.md) — <title> # link form
- T20260101-000002 # legacy plain form, still accepted
dev/EPICS/; the legacy single dev/EPICS.md is still read when the directory is absent.dev/EPICS/ and must follow the task file when it moves (close → ../JOURNAL/<date>-…, park → ../PARKING/…): _taskid/epic-links.sh sync does it, lint checks it./epic (epic/scripts/epic.sh) is the operator surface over these files: status (read-only), refresh (sync + lint + propose missing members), drive <E<n>> (member tasks in epic-file order, one /drive each, failures summarized and skipped, final report by priority).Deadline: is optional; omit the line entirely when an epic has none.tests/fixtures/epics/EPICS.md exercises and epic-status.sh's parser/render tests assert against — keep the two in sync rather than letting this doc drift from what the script actually parses._<name>/)Helpers shared across skills live in leading-underscore directories at the repo root (Claude Code only loads dirs containing SKILL.md, so these stay invisible as skills). Current set:
_taskid/ — mint/resolve task IDs, blob-link URLs, orphaned-ref checks._gh/gh.sh — gh wrapper that picks the authenticated account with access to the current repo. Use this everywhere instead of bare gh. On machines with multiple gh accounts, bare gh always uses the active account, which may not have access to the repo. The wrapper probes gh auth status accounts, prefers one with write access over a merely-readable one, and runs gh under it, scoped per-process via GH_TOKEN (no global mutation, no gh auth switch). Self-heals: if a cached pick still fails a write op with a permission-shaped error (e.g. "must be a collaborator"), it drops the cache entry and retries once with a different account (T20260911-140914).bash _gh/go/build.sh builds _gh/bin/ccxp-gh; with CCXP_GH_GO=1 both wrappers exec it (account list from gh auth status --json hosts, no text parsing). Off by default; if the binary is missing the bash path runs._gh/git.sh — same account-picking logic as gh.sh, for git commands that need GitHub auth over HTTPS (push, mainly). Use this instead of a bare git push on a multi-account machine — same rationale as gh.sh: don't hardcode GH_TOKEN="$(gh auth token --user <name>)" by hand, let the wrapper auto-detect the write-capable account (T20260911-140914). It also wires the repo's own local git config (.git/config only — never ~/.gitconfig) on every invocation: credential.helper is pointed at gh auth git-credential and url."https://github.com/".insteadOf is set for both git@github.com: and ssh://git@github.com/, so a plain git push/pull/fetch also authenticates over HTTPS as the right account instead of depending on the SSH agent's currently-loaded key. This is self-contained and idempotent — no hook, no prior session required — so it works correctly even on a fresh clone that has never run anything else in this repo (T20260925-219021)._session/ — on-main task claim lock, Project V2 board mirror, PR-ownership resolution, reclaim sweep for dead claims. See _session/README.md._ipm/ — current-iteration lookup and scheduled: stamping._journal/ — journal-move helpers for closing a task._docs/ — the shared markdown-lint wrapper (lint-docs.sh) and doc-impact/freshness checks.README.md and .git/ are ignored by Claude Code (only directories containing SKILL.md are loaded).hud/register.tsx 290 lines1// ccxp-hud: a Claude Code mod shipped inside the ccxp-skills plugin (T20261022-345549).
2//
3// - styles the user's own prompt rows (UserMessage render site)
4// - draws a status band above the prompt (AbovePrompt) fed by the bash helper
5// statusline-setup/scripts/hud-data.sh --json (claimant secret stays in bash)
6// - /hud on|off|status|pane
7//
8// ON by default; `enabled: false` in ~/.claude/ccxp-hud.json (or a project
9// override) makes every hook a pass-through. The file is re-read on every poll
10// and by /hud, so toggling is live; no reload needed.
11// Every hook is wrapped so a throw draws the engine's own row. No network, no
12// model and no MCP calls: only fs, process (one fixed argv), clock, state.
13
14import { atom, read, update } from 'claude-code'
15import type { Register } from 'claude-code'
16
17import type { HudConfig, Snapshot } from '../types'
18import { DEFAULTS, configPaths, heartbeatPath, mergeConfig, parseConfigText, withEnabled } from './config'
19import { EMPTY, compactLine, countJobs, parseHelper } from './model'
20import { bandTree, paneTree, userMessageTree } from './render'
21
22const PANE = 'ccxp-hud'
23const snapshotAtom = atom({ plugin: 'ccxp-skills', key: 'snapshot' } as const, EMPTY)
24const configAtom = atom({ plugin: 'ccxp-skills', key: 'config' } as const, DEFAULTS)
25
26// Module-level state: a hot reload starts it over, which is fine (the config and
27// snapshot live in $.state; these are only throttles and flags).
28const mem = {
29 surface: null as string | null,
30 bandSurface: null as string | null,
31 lastHelperAt: 0,
32 heartbeatIsOn: null as boolean | null,
33 started: false,
34}
35
36async function envPaths($: any) {
37 let configDir: string | undefined
38 let home: string | undefined
39 try {
40 configDir = await $.env.get('CLAUDE_CONFIG_DIR')
41 home = await $.env.get('HOME')
42 } catch {
43 /* no env: defaults below stay undefined, config then means defaults */
44 }
45 return { configDir, home }
46}
47
48async function readText($: any, path: string | undefined): Promise<string | undefined> {
49 if (!path) return undefined
50 try {
51 return await $.fs.read(path)
52 } catch {
53 return undefined
54 }
55}
56
57/** Loads project over user over defaults into the atom; never throws. */
58async function loadConfig($: any): Promise<HudConfig> {
59 let cfg = DEFAULTS
60 try {
61 const env = await envPaths($)
62 let root: string | undefined
63 try {
64 root = await $.session.root()
65 } catch {
66 root = undefined
67 }
68 const paths = configPaths(env, root)
69 cfg = mergeConfig(parseConfigText(await readText($, paths.user)), parseConfigText(await readText($, paths.project)))
70 } catch {
71 cfg = DEFAULTS
72 }
73 await update($, configAtom, () => cfg)
74 return cfg
75}
76
77async function writeHeartbeat($: any, value: 'on' | 'off') {
78 try {
79 const id = await $.session.id()
80 const path = heartbeatPath(await envPaths($), id)
81 if (path) await $.fs.write(path, `${value}\n`)
82 } catch {
83 /* best effort: the classic statusline then just prints in full */
84 }
85}
86
87async function syncHeartbeat($: any, cfg: HudConfig) {
88 // "on" only while a band is actually drawn (bandSurface is cleared when the
89 // band hook had nothing to show), so the statusline never shrinks for nothing.
90 const wantOn = cfg.enabled && cfg.band && mem.bandSurface !== null
91 // First call after a (re)load always writes, so a heartbeat left "on" by a
92 // previous module instance cannot outlive the mod being switched off.
93 // "on" is rewritten every call: the statusline treats an old file as off.
94 if (!wantOn && mem.heartbeatIsOn === false) return
95 await writeHeartbeat($, wantOn ? 'on' : 'off')
96 mem.heartbeatIsOn = wantOn
97}
98
99async function refresh($: any, cfg: HudConfig) {
100 if (!cfg.enabled) return
101 mem.lastHelperAt = await $.clock.now()
102 let helper = null
103 let isOk = false
104 try {
105 const cwd: string = await $.session.cwd()
106 const sessionId: string = await $.session.id()
107 const argv = ['bash', `${$.plugin.root}/statusline-setup/scripts/hud-data.sh`, '--json']
108 const res = await $.process.run(argv, {
109 cwd,
110 stdin: JSON.stringify({ cwd, session_id: sessionId }),
111 timeoutMs: 5000,
112 })
113 helper = res.exitCode === 0 ? parseHelper(res.stdout) : null
114 isOk = helper !== null
115 } catch {
116 isOk = false
117 }
118 let agents: ReadonlyArray<{ status: string }> = []
119 try {
120 agents = await $.agent.list()
121 } catch {
122 agents = []
123 }
124 const usage = await readUsage($)
125 await update($, snapshotAtom, (prev: Snapshot) => ({
126 ...prev,
127 ...usage,
128 helper: isOk ? helper : prev.helper,
129 stale: !isOk,
130 jobs: countJobs(agents),
131 }))
132}
133
134async function readUsage($: any): Promise<Partial<Snapshot>> {
135 try {
136 const u = await $.session.usage()
137 return usagePart(u)
138 } catch {
139 return {}
140 }
141}
142
143function usagePart(u: any): Partial<Snapshot> {
144 // Only the units present in `u` are returned, so a partial measurement
145 // (say context alone) never wipes the others.
146 const out: Partial<Snapshot> = {}
147 if (u?.context) {
148 const percent = u.context.percent
149 out.ctxLeft = typeof percent === 'number' ? Math.max(0, 100 - percent) : null
150 }
151 if (Array.isArray(u?.rateLimits)) {
152 const five = u.rateLimits.find((r: any) => r?.kind === 'five_hour')
153 out.rateUsed = typeof five?.percentUsed === 'number' ? five.percentUsed : null
154 }
155 if (u?.cost) out.costUsd = typeof u.cost.usd === 'number' ? u.cost.usd : null
156 return out
157}
158
159async function mirrorStatus($: any, cfg: HudConfig) {
160 // VS Code draws no band: mirror a compact line there. Elsewhere clear it.
161 try {
162 if (cfg.enabled && cfg.band && mem.surface === 'vscode') {
163 $.ui.status(compactLine(await read($, snapshotAtom), cfg))
164 } else {
165 $.ui.status(undefined)
166 }
167 } catch {
168 /* ignore */
169 }
170}
171
172async function tick($: any) {
173 try {
174 await tickOnce($)
175 } catch {
176 /* never throws: a failed tick leaves the previous state */
177 }
178}
179
180async function tickOnce($: any) {
181 const cfg = await loadConfig($)
182 if (!cfg.enabled) {
183 await syncHeartbeat($, cfg)
184 await mirrorStatus($, cfg)
185 return
186 }
187 const now = await $.clock.now()
188 if (now - mem.lastHelperAt >= cfg.pollSeconds * 1000) await refresh($, cfg)
189 await syncHeartbeat($, cfg)
190 await mirrorStatus($, cfg)
191}
192
193
194export const register: Register = on => {
195 on('session.start', async ($, e, next) => {
196 try {
197 mem.surface = e.surface ?? null
198 if (!mem.started) {
199 mem.started = true
200 try {
201 await $.command.register({ name: 'hud', description: 'ccxp-hud: on | off | status | pane' })
202 } catch {
203 /* the command is optional */
204 }
205 // The timer ticks at the minimum poll; each tick gates on the configured poll.
206 $.clock.every(5000, () => {
207 void tick($)
208 })
209 }
210 await tick($)
211 } catch {
212 /* the module stays inert; the session is never broken by the hud */
213 }
214 return next(e)
215 })
216
217 on('session.measure', async ($, e, next) => {
218 try {
219 const cfg = await read($, configAtom)
220 if (cfg.enabled) await update($, snapshotAtom, (prev: Snapshot) => ({ ...prev, ...usagePart(e) }))
221 } catch {
222 /* ignore */
223 }
224 return next(e)
225 })
226
227 on('turn.complete', async ($, e, next) => {
228 try {
229 const cfg = await read($, configAtom)
230 if (cfg.enabled) await refresh($, cfg)
231 } catch {
232 /* ignore */
233 }
234 return next(e)
235 })
236
237 on('command.run', { command: 'hud' }, async ($, e) => {
238 const arg = String((e as any).args ?? '').trim().toLowerCase()
239 try {
240 const env = await envPaths($)
241 const file = configPaths(env).user
242 if (arg === 'on' || arg === 'off') {
243 if (!file) return { text: 'ccxp-hud: cannot find the config dir. Edit ccxp-hud.json by hand: {"enabled": ' + (arg === 'on') + '}' }
244 try {
245 await $.fs.write(file, withEnabled(await readText($, file), arg === 'on'))
246 } catch {
247 return { text: `ccxp-hud: could not write ${file}. Set "enabled": ${arg === 'on'} in it by hand.` }
248 }
249 const cfg = await loadConfig($)
250 if (cfg.enabled) await refresh($, cfg)
251 await syncHeartbeat($, cfg)
252 await mirrorStatus($, cfg)
253 return { text: `ccxp-hud ${cfg.enabled ? 'on' : 'off'} (${file}). Takes effect now.` }
254 }
255 if (arg === 'pane') {
256 await $.ui.open({ id: PANE, title: 'ccxp-hud' })
257 return { text: 'ccxp-hud pane opened.' }
258 }
259 const cfg = await read($, configAtom)
260 return { text: `ccxp-hud is ${cfg.enabled ? 'on' : 'off'}. Config: ${file ?? '(unknown)'}. Usage: /hud on | off | status | pane` }
261 } catch {
262 return { text: 'ccxp-hud: something went wrong; the session is unaffected.' }
263 }
264 })
265
266 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
267 const cfg = await read($, configAtom)
268 if (!cfg.enabled || !cfg.userMessage) return next(e)
269 // Only the person's own composer rows; notifications and peers keep the engine's drawing.
270 if (e.props?.origin?.kind !== 'composer') return next(e)
271 // Desktop and VS Code draw their own frames: leave those rows alone.
272 if (e.surface !== 'terminal') return next(e)
273 const inner = await next(e)
274 return userMessageTree($.ui.resolve(e), cfg, inner)
275 }).catch(($, e, next) => next(e))
276
277 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
278 const cfg = await read($, configAtom)
279 if (!cfg.enabled || !cfg.band || e.props.hasSurvey) return next(e)
280 const tree = bandTree($.ui.resolve(e), await read($, snapshotAtom), cfg, e.props.bodyColumns)
281 mem.bandSurface = tree ? e.surface : null
282 return tree ?? next(e)
283 }).catch(($, e, next) => next(e))
284
285 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
286 const cfg = await read($, configAtom)
287 return paneTree($.ui.resolve(e), await read($, snapshotAtom), cfg)
288 }).catch(($, e, next) => next(e))
289}
290hud/config.ts 107 lines1// ccxp-hud config: pure functions only (no `$`), so the unit tests can pin them.
2// Missing, empty or malformed input always means the defaults, which are ON.
3
4import type { Field, HudConfig } from '../types'
5
6export type { Field, HudConfig }
7
8export const ALL_FIELDS: readonly Field[] = ['ap', 'ctx', 'branch', 'task', 'jobs', 'rate', 'cost', 'lastat']
9/** Text colour of the user's own prompt rows; the one place to change it. NO_COLOR is honoured by the renderer. */
10export const USER_TEXT_COLOR = 'yellow'
11export const BORDERS: readonly string[] = ['single', 'double', 'round', 'bold', 'singleDouble', 'doubleSingle', 'classic', 'quote']
12
13export const DEFAULTS: HudConfig = {
14 enabled: true,
15 userMessage: true,
16 band: true,
17 userBorder: 'quote',
18 userColor: 'suggestion',
19 fields: ['ap', 'ctx', 'branch', 'task', 'jobs', 'lastat'],
20 pollSeconds: 15,
21 ctxWarn: 40,
22 ctxCrit: 15,
23}
24
25type Raw = Record<string, unknown>
26
27function isRecord(x: unknown): x is Raw {
28 return typeof x === 'object' && x !== null && !Array.isArray(x)
29}
30
31function parseFields(x: unknown): Field[] | undefined {
32 const items = typeof x === 'string' ? x.split(',') : Array.isArray(x) ? x : undefined
33 if (!items) return undefined
34 const out: Field[] = []
35 for (const item of items) {
36 const name = String(item).trim() as Field
37 if (ALL_FIELDS.includes(name) && !out.includes(name)) out.push(name)
38 }
39 return out
40}
41
42/** Parses one config file's text into the keys that are valid; never throws. */
43export function parseConfigText(text: string | undefined | null): Partial<HudConfig> {
44 if (!text) return {}
45 let raw: unknown
46 try {
47 raw = JSON.parse(text)
48 } catch {
49 return {}
50 }
51 if (!isRecord(raw)) return {}
52 const out: Partial<HudConfig> = {}
53 for (const key of ['enabled', 'userMessage', 'band'] as const) {
54 if (typeof raw[key] === 'boolean') out[key] = raw[key] as boolean
55 }
56 if (typeof raw.userBorder === 'string') out.userBorder = raw.userBorder
57 if (typeof raw.userColor === 'string' && raw.userColor.trim() !== '') out.userColor = raw.userColor.trim()
58 const fields = parseFields(raw.fields)
59 if (fields) out.fields = fields
60 for (const key of ['pollSeconds', 'ctxWarn', 'ctxCrit'] as const) {
61 if (typeof raw[key] === 'number' && Number.isFinite(raw[key])) out[key] = raw[key] as number
62 }
63 return out
64}
65
66/** Project over user over defaults, with the numeric keys clamped. */
67export function mergeConfig(user: Partial<HudConfig>, project: Partial<HudConfig>): HudConfig {
68 const cfg: HudConfig = { ...DEFAULTS, ...user, ...project }
69 cfg.pollSeconds = Math.max(5, Math.round(cfg.pollSeconds))
70 cfg.ctxWarn = Math.min(100, Math.max(0, cfg.ctxWarn))
71 cfg.ctxCrit = Math.min(cfg.ctxWarn, Math.max(0, cfg.ctxCrit))
72 return cfg
73}
74
75/** The border to draw, or undefined for none (an unknown name draws no border). */
76export function borderOf(cfg: HudConfig): string | undefined {
77 return BORDERS.includes(cfg.userBorder) ? cfg.userBorder : undefined
78}
79
80/** The user-level file path and the optional per-project override. */
81export function configPaths(env: { configDir?: string; home?: string }, projectRoot?: string) {
82 const base = env.configDir || (env.home ? `${env.home}/.claude` : undefined)
83 return {
84 user: base ? `${base}/ccxp-hud.json` : undefined,
85 project: projectRoot ? `${projectRoot}/.claude/ccxp-hud.json` : undefined,
86 }
87}
88
89/** Text of the user file after `/hud on|off`: other keys kept, `enabled` set. */
90export function withEnabled(text: string | undefined | null, enabled: boolean): string {
91 let raw: Raw = {}
92 try {
93 const parsed: unknown = text ? JSON.parse(text) : {}
94 if (isRecord(parsed)) raw = parsed
95 } catch {
96 raw = {}
97 }
98 return JSON.stringify({ ...raw, enabled }, null, 2) + '\n'
99}
100
101/** Where the heartbeat for a session lives (the statusline reads the same path). */
102export function heartbeatPath(env: { configDir?: string; home?: string }, sessionId: string): string | undefined {
103 const base = env.configDir || (env.home ? `${env.home}/.claude` : undefined)
104 if (!base || !sessionId || /[\\/]/.test(sessionId) || sessionId === '.' || sessionId === '..') return undefined
105 return `${base}/state/hud-active/${sessionId}`
106}
107hud/model.ts 222 lines1// ccxp-hud model: pure snapshot + band layout. No `$`, so it is unit-testable.
2
3import type { Autopilot, Field, HelperData, HudConfig, Jobs, Ledger, Snapshot } from '../types'
4
5export type { Autopilot, HelperData, Jobs, Snapshot }
6
7export const EMPTY: Snapshot = {
8 helper: null,
9 ctxLeft: null,
10 rateUsed: null,
11 costUsd: null,
12 jobs: { running: 0, waiting: 0, failed: 0 },
13 stale: false,
14}
15
16function str(x: unknown): string {
17 return typeof x === 'string' ? x : ''
18}
19
20function num(x: unknown): number {
21 return typeof x === 'number' && Number.isFinite(x) ? x : 0
22}
23
24/** Validates the helper's stdout; null on anything unusable (the mod then goes stale). */
25export function parseHelper(stdout: string): HelperData | null {
26 let raw: unknown
27 try {
28 raw = JSON.parse(stdout)
29 } catch {
30 return null
31 }
32 if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null
33 const r = raw as Record<string, unknown>
34 // "{}" is the helper's own failure answer.
35 if (!('branch' in r) && !('task_id' in r)) return null
36 const ap = r.autopilot
37 let autopilot: Autopilot | null = null
38 if (typeof ap === 'object' && ap !== null && !Array.isArray(ap)) {
39 const a = ap as Record<string, unknown>
40 autopilot = {
41 status: str(a.status),
42 prefix: str(a.prefix),
43 elapsed_hr: num(a.elapsed_hr),
44 requested_hr: num(a.requested_hr),
45 stuck: num(a.stuck),
46 cycle: num(a.cycle),
47 last_outcome: str(a.last_outcome),
48 last_task: str(a.last_task),
49 stuck_reason: str(a.stuck_reason),
50 stop_reason: str(a.stop_reason),
51 merged: num(a.merged),
52 }
53 }
54 const led = r.ledger
55 let ledger: Ledger | null = null
56 if (typeof led === 'object' && led !== null && !Array.isArray(led)) {
57 const l = led as Record<string, unknown>
58 ledger = {
59 running: (Array.isArray(l.running) ? l.running : [])
60 .filter((x): x is Record<string, unknown> => typeof x === 'object' && x !== null)
61 .map(x => ({ job: num(x.job), title: str(x.title) })),
62 lifetime_jobs: num(l.lifetime_jobs),
63 parked: num(l.parked),
64 parked_ids: (Array.isArray(l.parked_ids) ? l.parked_ids : []).filter((x): x is string => typeof x === 'string'),
65 next_job: num(l.next_job),
66 updated_at: str(l.updated_at),
67 }
68 }
69 return {
70 task_id: str(r.task_id),
71 task_title: str(r.task_title),
72 branch: str(r.branch),
73 worktree: str(r.worktree),
74 autopilot,
75 ledger,
76 last_input_at: num(r.last_input_at),
77 }
78}
79
80/** Counts the session's agents by what the band shows. */
81export function countJobs(agents: ReadonlyArray<{ status: string }>): Jobs {
82 const jobs = { running: 0, waiting: 0, failed: 0 }
83 for (const a of agents) {
84 if (a.status === 'running' || a.status === 'pending') jobs.running += 1
85 else if (a.status === 'waiting' || a.status === 'idle') jobs.waiting += 1
86 else if (a.status === 'failed' || a.status === 'killed') jobs.failed += 1
87 }
88 return jobs
89}
90
91export type Tone = 'success' | 'warning' | 'error'
92
93/** Green above `ctxWarn`, yellow down to `ctxCrit`, red below. */
94export function ctxTone(left: number, cfg: Pick<HudConfig, 'ctxWarn' | 'ctxCrit'>): Tone {
95 if (left > cfg.ctxWarn) return 'success'
96 if (left >= cfg.ctxCrit) return 'warning'
97 return 'error'
98}
99
100export function meter(left: number, width = 10): string {
101 const filled = Math.max(0, Math.min(width, Math.round((left / 100) * width)))
102 return '▓'.repeat(filled) + '░'.repeat(width - filled)
103}
104
105export type Seg = { text: string; color?: string; dim?: boolean; bold?: boolean }
106
107const AP_LOOK: Record<string, { glyph: string; word: string; color: string }> = {
108 '[r]': { glyph: '●', word: 'run', color: 'success' },
109 '[b]': { glyph: '●', word: 'run', color: 'success' }, // backing off is still running; ⚠ carries the stuck count
110 '[s]': { glyph: '○', word: 'stopped', color: 'inactive' },
111 '[p]': { glyph: '⏸', word: 'paused', color: 'warning' },
112}
113
114/** A stop reason that means something went wrong (not elapsed / queue-empty / user-requested). */
115export function isFailureStop(reason: string): boolean {
116 return /fail|error|crash|abort/i.test(reason)
117}
118
119/** Timestamp format of the last-input field, in one place: local HH:MM, plus "Mon D" when not today. */
120export function formatStamp(epochSec: number, nowMs: number = Date.now()): string {
121 const d = new Date(epochSec * 1000)
122 const now = new Date(nowMs)
123 const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
124 if (d.toDateString() === now.toDateString()) return hm
125 return `${d.toLocaleString('en-US', { month: 'short' })} ${d.getDate()} ${hm}`
126}
127
128export function truncate(text: string, max: number): string {
129 if (max <= 0) return ''
130 return text.length <= max ? text : text.slice(0, Math.max(1, max - 1)) + '…'
131}
132
133function segFor(field: Field, snap: Snapshot, cfg: HudConfig, room: number): Seg[] | null {
134 const h = snap.helper
135 switch (field) {
136 case 'ap': {
137 const ap = h?.autopilot
138 if (!ap) return null
139 const look = { ...(AP_LOOK[ap.prefix] ?? { glyph: '·', word: ap.status, color: 'inactive' }) }
140 if (ap.prefix === '[s]' && isFailureStop(ap.stop_reason)) look.color = 'error'
141 const segs: Seg[] = [
142 { text: 'AP ', bold: true },
143 { text: `${look.glyph} ${look.word} `, color: look.color },
144 { text: `${ap.elapsed_hr}/${ap.requested_hr}h ` },
145 ]
146 if (ap.stuck > 0) segs.push({ text: `⚠${ap.stuck} `, color: 'error' })
147 segs.push({ text: `loops ${ap.cycle} ` }, { text: `✓${ap.merged}`, color: ap.merged > 0 ? 'success' : undefined, dim: ap.merged === 0 })
148 return segs
149 }
150 case 'ctx': {
151 if (snap.ctxLeft === null) return null
152 const left = Math.round(snap.ctxLeft)
153 return [{ text: `ctx ${left}% ${meter(left)}`, color: ctxTone(left, cfg) }]
154 }
155 case 'branch': {
156 const b = h?.branch
157 if (!b) return null
158 return [{ text: ` ${b}${h?.worktree ? ` (wt ${h.worktree})` : ''}` }]
159 }
160 case 'task': {
161 if (!h) return null
162 if (!h.task_id) return [{ text: 'no claimed task', dim: true }]
163 return [{ text: truncate(`${h.task_id} ${h.task_title}`.trim(), room) }]
164 }
165 case 'jobs': {
166 const l = h?.ledger
167 if (!l) return [{ text: 'jobs —', dim: true }]
168 const run = l.running.length
169 const segs: Seg[] = [
170 { text: 'jobs run ' },
171 { text: `${run}`, color: run > 0 ? 'success' : undefined },
172 { text: ` · total ${l.lifetime_jobs} · parked ` },
173 { text: `${l.parked}`, color: l.parked > 0 ? 'warning' : undefined },
174 ]
175 // Wide layout only (room is columns - 60): show what is running.
176 if (run > 0 && room >= 40) {
177 const names = l.running.map(j => `r${j.job}${j.title ? ' ' + j.title : ''}`).join(', ')
178 segs.push({ text: ` (${truncate(names, room - 10)})`, dim: true })
179 }
180 return segs
181 }
182 case 'rate':
183 return snap.rateUsed === null ? null : [{ text: `5h ${Math.round(snap.rateUsed)}%` }]
184 case 'cost':
185 return snap.costUsd === null ? null : [{ text: `$${snap.costUsd.toFixed(2)}` }]
186 case 'lastat': {
187 const at = h?.last_input_at
188 return at ? [{ text: `you ${formatStamp(at)}`, dim: true }] : null
189 }
190 }
191}
192
193/**
194 * The band as rows of segments. One row from ~100 columns up, two below.
195 * Empty when there is nothing to show (the caller then yields to the engine).
196 */
197export function bandRows(snap: Snapshot, cfg: HudConfig, columns: number): Seg[][] {
198 const blocks: Seg[][] = []
199 for (const f of cfg.fields) {
200 const block = segFor(f, snap, cfg, Math.max(12, columns - 60))
201 if (block) blocks.push(block)
202 }
203 if (blocks.length === 0) return []
204 if (snap.stale) blocks.push([{ text: 'stale', dim: true }])
205
206 const narrow = columns < 100
207 const rows: Seg[][] = narrow ? [[], []] : [[]]
208 const half = Math.ceil(blocks.length / 2)
209 blocks.forEach((block, i) => {
210 const row = narrow ? rows[i < half ? 0 : 1] : rows[0]
211 if (row.length > 0) row.push({ text: ' │ ', dim: true })
212 row.push(...block)
213 })
214 return rows.filter(r => r.length > 0)
215}
216
217/** One plain line for surfaces with no band (VS Code `$.ui.status`), 2,000 chars at most. */
218export function compactLine(snap: Snapshot, cfg: HudConfig): string {
219 const rows = bandRows(snap, cfg, 200)
220 return rows.map(r => r.map(s => s.text).join('')).join(' ').slice(0, 2000)
221}
222hud/render.tsx 74 lines1// ccxp-hud trees. The element constructors come from `$.ui.resolve(e)` and are
2// passed in, so these stay free of `$` and the tests can drive them.
3
4import type { HudConfig, Snapshot } from '../types'
5import { USER_TEXT_COLOR, borderOf } from './config'
6import { bandRows } from './model'
7
8/** The prompt row wrapped in the configured frame; `inner` is the engine's own drawing. */
9export function userMessageTree(
10 ui: { Box: any; Text: any },
11 cfg: HudConfig,
12 inner: unknown,
13) {
14 const { Box, Text } = ui
15 const border = borderOf(cfg)
16 return (
17 <Box borderStyle={border} borderColor={cfg.userColor} paddingX={border ? 1 : 0}>
18 <Text color={USER_TEXT_COLOR}>{inner}</Text>
19 </Box>
20 )
21}
22
23/** The status band, or null when there is nothing to draw. */
24export function bandTree(
25 ui: { Box: any; Text: any },
26 snap: Snapshot,
27 cfg: HudConfig,
28 columns: number,
29) {
30 const { Box, Text } = ui
31 const rows = bandRows(snap, cfg, columns)
32 if (rows.length === 0) return null
33 return (
34 <Box flexDirection="column" paddingX={1}>
35 {rows.map(row => (
36 <Box flexDirection="row">
37 {row.map(seg => (
38 <Text color={seg.color} dimColor={seg.dim} bold={seg.bold}>
39 {seg.text}
40 </Text>
41 ))}
42 </Box>
43 ))}
44 </Box>
45 )
46}
47
48/** The `/hud pane` detail view. */
49export function paneTree(ui: { Box: any; Text: any }, snap: Snapshot, cfg: HudConfig) {
50 const { Box, Text } = ui
51 const ap = snap.helper?.autopilot
52 const lines: string[] = [
53 `enabled: ${cfg.enabled} band: ${cfg.band} userMessage: ${cfg.userMessage}`,
54 ap
55 ? `autopilot ${ap.status} ${ap.prefix} ${ap.elapsed_hr}/${ap.requested_hr}h ${ap.stuck > 0 ? `⚠${ap.stuck} ` : ''}loops ${ap.cycle} ✓${ap.merged}`
56 : 'autopilot: not running',
57 ]
58 if (ap?.last_task) lines.push(`last task: ${ap.last_task}`)
59 if (ap?.stuck_reason) lines.push(`last stuck reason: ${ap.stuck_reason}`)
60 if (ap?.stop_reason) lines.push(`stop reason: ${ap.stop_reason}`)
61 const led = snap.helper?.ledger
62 lines.push(led ? `jobs: run ${led.running.length} · total ${led.lifetime_jobs} · parked ${led.parked}${led.parked_ids.length ? ` (${led.parked_ids.join(' ')})` : ''}` : 'jobs: no ledger')
63 lines.push(`agents: ${snap.jobs.running} running, ${snap.jobs.waiting} waiting, ${snap.jobs.failed} failed`)
64 if (snap.ctxLeft !== null) lines.push(`context left: ${Math.round(snap.ctxLeft)}%`)
65 if (snap.stale) lines.push('data is stale (the helper failed or timed out)')
66 return (
67 <Box flexDirection="column">
68 {lines.map(l => (
69 <Text>{l}</Text>
70 ))}
71 </Box>
72 )
73}
74types/index.d.ts 73 lines1// Types and $.state contract of the ccxp-hud mod (hud/register.tsx). The
2// contract must be self-contained, so the shared shapes are defined here and the
3// module's files import them from '../types'.
4
5export type Field = 'ap' | 'ctx' | 'branch' | 'task' | 'jobs' | 'rate' | 'cost' | 'lastat'
6
7export type HudConfig = {
8 enabled: boolean
9 userMessage: boolean
10 band: boolean
11 userBorder: string
12 userColor: string
13 fields: Field[]
14 pollSeconds: number
15 ctxWarn: number
16 ctxCrit: number
17}
18
19export type Autopilot = {
20 status: string
21 prefix: string
22 elapsed_hr: number
23 requested_hr: number
24 stuck: number
25 cycle: number
26 last_outcome: string
27 last_task: string
28 stuck_reason: string
29 stop_reason: string
30 /** Tasks merged this run; 0 for state files written before the field existed. */
31 merged: number
32}
33
34export type Ledger = {
35 running: { job: number; title: string }[]
36 lifetime_jobs: number
37 parked: number
38 parked_ids: string[]
39 next_job: number
40 updated_at: string
41}
42
43export type HelperData = {
44 task_id: string
45 task_title: string
46 branch: string
47 worktree: string
48 autopilot: Autopilot | null
49 /** Job ledger (statusline-setup/scripts/ledger.sh), null when there is none. */
50 ledger: Ledger | null
51 /** Epoch seconds of the session's last submitted prompt, 0 when unknown. */
52 last_input_at: number
53}
54
55export type Jobs = { running: number; waiting: number; failed: number }
56
57export type Snapshot = {
58 helper: HelperData | null
59 /** Context left, 0-100, or null before the first reading. */
60 ctxLeft: number | null
61 rateUsed: number | null
62 costUsd: number | null
63 jobs: Jobs
64 /** True when the last helper run failed: the helper part is the previous one. */
65 stale: boolean
66}
67
68declare module 'claude-code' {
69 interface PluginState {
70 'ccxp-skills': { snapshot: Snapshot; config: HudConfig }
71 }
72}
73