Curated skills that work together for a complete dev workflow

A Claude Code + Codex skills plugin: a curated set of skills that work together for a complete dev workflow — brainstorm, plan, TDD, debug, review, ship — with reliable triggering, enforced discipline, and automated session documentation.
.ai/sessions/ scaffolding named automatically from Claude Code's own session title, pre-compact snapshots, and handoffs, maintained by hooks.skills/<name>/SKILL.md — portable skills (one source, read by both CC and Codex). Full list: CATALOG.md.agents/ — specialist subagents for dispatched review and debugging.hooks/ — session bootstrap, safety guards, and session-doc automation.rules/ — standing conventions. docs/ — design notes, PLAN v2 template, CC↔Codex parity matrix.scripts/ — install.sh, gen-catalog.py, gen-bootstrap.py, new-session.sh, state.js (a session's workflow state as JSON).Claude Code — load as a plugin:
/plugin marketplace add TrevorEdris/constellation
/plugin install constellation@constellation
This wires skills, agents, and hooks from the manifest.
Codex — symlink skills into the official ~/.agents/skills/ path:
bash scripts/install.sh # link skills for Codex
bash scripts/install.sh --dry-run # preview
See .codex/INSTALL.md.
Claude Code only. /constellation opens a pane beside the transcript (inline above the prompt where the terminal cannot dock it) that shows the session's workflow state: the session dir, the phases drawn as a small star chart with the approval gate, each plan's validator score, size and open questions, the session files with their ages, the skills used, and each plan's delivery branch with a warning when you are on another one. Click a session file or a plan to read it in the pane; a delivery branch with a pull request links to it (looked up with gh). Its buttons steer the work by prompting Claude: validate or render the plan card, continue, save a handoff. Approve and Request changes only fill the prompt, so you finish the reply yourself. The status line shows the phase even with the pane closed. /constellation open and /constellation close force either state.
The pane reads node scripts/state.js --session-id <id>, which prints the same state as JSON.
Drop skills/<name>/SKILL.md (frontmatter: name + description only; description starts with "Use when …"). Run python scripts/gen-catalog.py to register it. No manifest edit needed.
SKILL.md per skill — no per-platform forks.description = WHEN to trigger, never a workflow summary.Builds on ideas from superpowers (skill-effectiveness discipline) and fellowship-of-the-workflows (spec-driven planning and session automation).
hooks/console/register.tsx 380 lines1/**
2 * The constellation console: a pane (/constellation) and a status-line entry
3 * that show the session's workflow state, with buttons that steer it by
4 * prompting Claude, so every state change still goes through a skill.
5 *
6 * State comes from scripts/state.js, run on session start, after Claude
7 * writes into the session dir, at the end of each turn, when a watched file
8 * changes, and every 15 s while the pane is open. Refreshes never overlap: a
9 * request that arrives during one runs once more after it.
10 */
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, Register, Timer } from 'claude-code'
13
14import type { ConsolePr, ConsoleState } from '../../types'
15import {
16 ACTIVITY_MS, RAIL, STAR, actions, age, branchWarnings, constellationSvg, currentStep, deliveryRows, firstLine,
17 isOpenPlan, isUnder, nextScores, prKey, pushActivity, pushSkill, railGlyphs, sessionTitle, statusLine, statusSentence,
18} from './view'
19
20const PANE = 'constellation-console'
21const TITLE = 'Constellation'
22const POLL_MS = 15_000
23const STATE_TIMEOUT_MS = 5_000
24const GH_TIMEOUT_MS = 10_000
25const PR_TTL_MS = 5 * 60_000
26
27const snapshot = atom({ plugin: 'constellation', key: 'snapshot' } as const, null)
28const error = atom({ plugin: 'constellation', key: 'error' } as const, null)
29const scores = atom({ plugin: 'constellation', key: 'scores' } as const, {})
30const activity = atom({ plugin: 'constellation', key: 'activity' } as const, [])
31const skills = atom({ plugin: 'constellation', key: 'skills' } as const, [])
32const preview = atom({ plugin: 'constellation', key: 'preview' } as const, null)
33const prs = atom({ plugin: 'constellation', key: 'prs' } as const, {})
34
35let running: Promise<void> | null = null
36let again = false
37let poll: Timer | null = null
38
39async function refreshOnce($: EngineInterface): Promise<void> {
40 try {
41 const id = await $.session.id()
42 const cache = await read($, scores)
43 const res = await $.process.run(
44 ['node', `${$.plugin.root}/scripts/state.js`, '--session-id', id, '--cache-stdin'],
45 { stdin: JSON.stringify({ scores: cache }), timeoutMs: STATE_TIMEOUT_MS },
46 )
47 if (res.exitCode !== 0) throw new Error(firstLine(res.stderr) || `state.js exited ${res.exitCode}`)
48 const state = JSON.parse(res.stdout) as ConsoleState
49 await update($, snapshot, () => state)
50 await update($, scores, () => nextScores(state))
51 await update($, error, () => null)
52 } catch (err) {
53 try {
54 await update($, error, () => firstLine(err instanceof Error ? err.message : String(err)) || 'unknown error')
55 } catch {
56 // The error atom is best-effort; a refresh never throws.
57 }
58 }
59 try {
60 $.ui.status(statusLine(await read($, snapshot)))
61 } catch {
62 // The status line is best-effort.
63 }
64 try {
65 const shown = await read($, preview)
66 if (shown) await showFile($, shown.path)
67 const s = await read($, snapshot)
68 if (s) await lookUpPrs($, s)
69 } catch {
70 // The preview and PR links are best-effort; a refresh never throws.
71 }
72}
73
74/** Shows a file inside the pane, or why it could not be read. */
75async function showFile($: EngineInterface, path: string): Promise<void> {
76 const name = path.split('/').pop() ?? path
77 try {
78 const text = await $.fs.read(path)
79 await update($, preview, () => ({ name, path, text: String(text), error: null }))
80 } catch (err) {
81 const reason = firstLine(err instanceof Error ? err.message : String(err)) || 'unreadable'
82 await update($, preview, () => ({ name, path, text: null, error: reason }))
83 }
84}
85
86/** Looks up the PR of each shipping branch, at most once per 5 minutes per branch. */
87async function lookUpPrs($: EngineInterface, s: ConsoleState): Promise<void> {
88 const known = await read($, prs)
89 const now = await $.clock.now()
90 for (const row of deliveryRows(s)) {
91 if (!row.repoPath.startsWith('/') || row.branch === '?' || row.branch === 'n/a') continue
92 const key = prKey(row.repoPath, row.branch)
93 const seen = known[key]
94 if (seen && now - seen.at < PR_TTL_MS) continue
95 const pr = await ghPr($, row.repoPath, row.branch)
96 await update($, prs, all => ({ ...all, [key]: { pr, at: now } }))
97 }
98}
99
100/**
101 * The newest PR whose head is `branch`, from `gh pr list` run in the repo. A
102 * failed call is tried once more with GITHUB_TOKEN emptied, so gh falls back
103 * to its keyring login when an exported token is stale. Null when gh is
104 * missing, both calls fail, or the branch has no PR.
105 */
106async function ghPr($: EngineInterface, repo: string, branch: string): Promise<ConsolePr | null> {
107 const argv = ['gh', 'pr', 'list', '--head', branch, '--state', 'all', '--json', 'number,url,state', '--limit', '1']
108 for (const env of [undefined, { GITHUB_TOKEN: '' }]) {
109 try {
110 const res = await $.process.run(argv, { cwd: repo, timeoutMs: GH_TIMEOUT_MS, ...(env ? { env } : {}) })
111 if (res.exitCode !== 0) continue
112 const first: unknown = (JSON.parse(res.stdout) as unknown[])[0]
113 if (!first || typeof first !== 'object') return null
114 const { number, url, state } = first as Record<string, unknown>
115 return typeof number === 'number' && typeof url === 'string' ? { number, url, state: String(state ?? '') } : null
116 } catch {
117 // gh missing, timed out or printed something else: try the next way, then give up.
118 }
119 }
120 return null
121}
122
123async function refresh($: EngineInterface): Promise<void> {
124 if (running) {
125 again = true
126 return running
127 }
128 running = (async () => {
129 try {
130 do {
131 again = false
132 await refreshOnce($)
133 } while (again)
134 } finally {
135 running = null
136 }
137 })()
138 return running
139}
140
141/** Starts a refresh without waiting for it, so an event never waits on the CLI. */
142function refreshLater($: EngineInterface): void {
143 void refresh($).catch(() => undefined)
144}
145
146async function open($: EngineInterface): Promise<string> {
147 const placed = await $.ui.open({ id: PANE, title: TITLE, columns: 46 })
148 if (!placed.isPlaced) return `Constellation pane not shown: ${placed.reason}`
149 poll?.cancel()
150 poll = $.clock.every(POLL_MS, () => void refresh($))
151 await refresh($)
152 return 'Constellation pane opened.'
153}
154
155async function close($: EngineInterface): Promise<string> {
156 poll?.cancel()
157 poll = null
158 await $.ui.close({ id: PANE })
159 return 'Constellation pane closed.'
160}
161
162export const register: Register = on => {
163 on('session.start', async ($, e, next) => {
164 await $.command.register({
165 name: 'constellation',
166 description: 'Show the constellation workflow pane',
167 argumentHint: '[open|close]',
168 })
169 refreshLater($)
170 return next(e)
171 })
172
173 on('command.run', { command: 'constellation' }, async ($, e) => {
174 const arg = e.args.trim()
175 if (arg === 'open') return { text: await open($) }
176 if (arg === 'close') return { text: await close($) }
177 if (arg !== '') return { text: 'usage: /constellation [open|close]' }
178 const panes = await $.ui.panes()
179 const isOpen = panes.some(p => p.id === PANE)
180 return { text: isOpen ? await close($) : await open($) }
181 })
182
183 on('tool.call', async ($, e, next) => {
184 const result = await next(e)
185 const file = e.tool === 'Write' || e.tool === 'Edit'
186 ? e.file_path
187 : e.tool === 'NotebookEdit' ? e.notebook_path : null
188 if (!file) return result
189 const dir = (await read($, snapshot))?.sessionDir ?? null
190 if (dir && !isUnder(file, dir)) return result
191 if (dir) {
192 const now = await $.clock.now()
193 await update($, activity, list => pushActivity(list, file, now))
194 }
195 refreshLater($)
196 return result
197 })
198
199 on('skill.prompt', async ($, e, next) => {
200 if (e.skill.startsWith('constellation:')) await update($, skills, list => pushSkill(list, e.skill))
201 return next(e)
202 })
203
204 on('turn.complete', async ($, e, next) => {
205 const result = await next(e)
206 refreshLater($)
207 return result
208 })
209
210 on('classic.SessionStart', async ($, e, next) => {
211 const result = await next(e)
212 // Must match sessionRoot() in hooks/lib/session.js: SESSION_ROOT if set, else $HOME/src/.ai/sessions.
213 const fromEnv = await $.env.get('SESSION_ROOT')
214 const home = await $.env.get('HOME')
215 const root = fromEnv || (home ? `${home}/src/.ai/sessions` : null)
216 const s = await read($, snapshot)
217 refreshLater($)
218 const watch = [...(root ? [`${root}/.sessions`] : []), ...(s?.sessionDir ? [s.sessionDir] : [])]
219 return { ...result, watchPaths: [...(result.watchPaths ?? []), ...watch] }
220 })
221
222 on('classic.FileChanged', async ($, e, next) => {
223 const result = await next(e)
224 refreshLater($)
225 return result
226 })
227
228 on('ui.close', async ($, e, next) => {
229 if (e.id === PANE) {
230 poll?.cancel()
231 poll = null
232 }
233 return next(e)
234 })
235
236 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
237 const els = $.ui.resolve(e)
238 const { Box, Text, Button, Link, Markdown } = els
239 const s = await read($, snapshot)
240 const shown = await read($, preview)
241 const err = await read($, error)
242 const writes = await read($, activity)
243 const used = await read($, skills)
244 const now = await $.clock.now()
245 const problem = err && <Text color="warning">Couldn't read session state: {err}</Text>
246
247 if (!s || !s.sessionDir) {
248 return (
249 <Box flexDirection="column">
250 {problem}
251 <Text>No session journal yet.</Text>
252 <Text dimColor>It starts when this turn ends.</Text>
253 </Box>
254 )
255 }
256
257 if (shown) {
258 return (
259 <Box flexDirection="column">
260 <Box flexDirection="row" gap={1}>
261 <Button key="back" label="Back" hotkey="b" onPress={() => void update($, preview, () => null)} />
262 <Text bold wrap="truncate-end">{shown.name}</Text>
263 </Box>
264 <Box flexDirection="column" marginTop={1}>
265 {shown.error === null
266 ? <Markdown text={shown.text ?? ''} />
267 : <Text color="warning">Couldn't open {shown.name}: {shown.error}</Text>}
268 </Box>
269 </Box>
270 )
271 }
272
273 const dir = s.sessionDir
274 const at = RAIL.findIndex(r => r.step === currentStep(s))
275 const writing = new Set(writes.filter(w => now - w.at < ACTIVITY_MS).map(w => w.path))
276 const plans = s.plans.slice(0, 2)
277 const shipping = deliveryRows(s)
278 const prLookups = await read($, prs)
279 const warnings = branchWarnings(s)
280
281 return (
282 <Box flexDirection="column">
283 {problem}
284 <Text bold wrap="truncate-end">{sessionTitle(dir)}</Text>
285 <Box flexDirection="row" gap={1}>
286 <Box flexGrow={1} flexShrink={1}>
287 <Text dimColor wrap="truncate-start">{dir}</Text>
288 </Box>
289 <Button key="copy" label="copy path" hotkey="y" plain dimColor onPress={() => void $.ui.copy({ text: dir, surface: e.surface })} />
290 </Box>
291
292 <Box flexDirection="column" marginTop={1}>
293 {'Svg' in els
294 ? <els.Svg source={constellationSvg(at)} alt={`Phase: ${RAIL[at]?.label ?? 'none'}`} />
295 : (
296 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
297 {railGlyphs(at).map(g => (
298 <Text key={`rail-${g.label}`} color={g.state === 'ahead' ? undefined : STAR} dimColor={g.state === 'ahead'} bold={g.state === 'current'}>
299 {g.glyph} {g.label}
300 </Text>
301 ))}
302 </Box>
303 )}
304 <Text wrap="wrap">{statusSentence(s)}</Text>
305 </Box>
306 <Box flexDirection="row" gap={1} flexWrap="wrap" marginTop={1}>
307 {actions(s).map((a, i) => (
308 <Button
309 key={a.key}
310 label={a.label}
311 hotkey={a.hotkey}
312 variant={i === 0 ? 'primary' : 'secondary'}
313 onPress={() => void (a.kind === 'fill'
314 ? $.prompt.fill({ text: a.text, mode: 'insert' })
315 : $.prompt.submit({ text: a.text }))}
316 />
317 ))}
318 </Box>
319
320 {plans.length > 0 && <Box marginTop={1}><Text bold>Plans</Text></Box>}
321 {plans.map(p => (
322 <Box key={`plan-${p.path}`} flexDirection="column" marginTop={1}>
323 <Button key={`plan:${p.path}`} plain label={p.title ?? p.path.split('/').pop() ?? p.path} onPress={() => void showFile($, p.path)} />
324 <Text wrap="truncate-end">
325 <Text dimColor>{p.status ?? 'no status'}, </Text>
326 <Text color={p.score === null ? 'inactive' : p.passed ? 'success' : 'warning'}>
327 {p.score === null ? 'not validated' : `validator ${p.score}, ${p.passed ? 'passing' : 'needs work'}`}
328 </Text>
329 </Text>
330 {p.size && <Text dimColor wrap="truncate-end">{p.size}</Text>}
331 {isOpenPlan(p) && p.needsCall.map(c => (
332 <Text key={`call-${p.path}-${c.n}`} wrap="truncate-end">
333 {c.n}. {c.text} <Text dimColor>Default: {c.default}</Text>
334 </Text>
335 ))}
336 </Box>
337 ))}
338 {s.plans.length > plans.length && <Text dimColor>{s.plans.length - plans.length} more</Text>}
339
340 {s.docs.length > 0 && <Box marginTop={1}><Text bold>Session files</Text></Box>}
341 {s.docs.map(d => {
342 const isWriting = writing.has(`${dir}/${d.name}`)
343 return (
344 <Box key={`docrow:${d.name}`} flexDirection="row" justifyContent="space-between" gap={1}>
345 <Button key={`doc:${d.name}`} plain label={d.name} onPress={() => void showFile($, `${dir}/${d.name}`)} />
346 <Text color={isWriting ? STAR : undefined} dimColor={!isWriting}>{isWriting ? 'writing' : age(now, d.mtimeMs)}</Text>
347 </Box>
348 )
349 })}
350 {used.length > 0 && (
351 <Box marginTop={1}>
352 <Text dimColor wrap="truncate-end">Skills used: {used.map(n => n.replace(/^constellation:/, '')).join(', ')}</Text>
353 </Box>
354 )}
355
356 <Box flexGrow={1} />
357 {(shipping.length > 0 || warnings.length > 0) && <Box marginTop={1}><Text bold>Shipping</Text></Box>}
358 {shipping.map(d => {
359 const pr = prLookups[prKey(d.repoPath, d.branch)]?.pr ?? null
360 return pr
361 ? (
362 <Box key={`delivery-${d.key}`} flexDirection="row" gap={1}>
363 <Link href={pr.url} label={`#${pr.number} ${d.branch} → ${d.base}`} />
364 <Text dimColor>{pr.state.toLowerCase()}</Text>
365 </Box>
366 )
367 : (
368 <Text key={`delivery-${d.key}`} wrap="truncate-end">
369 {d.branch} → {d.base} <Text dimColor>{d.repo}, no PR yet</Text>
370 </Text>
371 )
372 })}
373 {warnings.map((w, i) => (
374 <Text key={`warn-${i}`} color={w.level}>{w.text}</Text>
375 ))}
376 </Box>
377 )
378 })
379}
380hooks/console/view.ts 230 lines1/**
2 * What the console pane shows, worked out from scripts/state.js output with
3 * no engine calls: the phase rail, the buttons for the current phase, ages,
4 * branch warnings and the status-line text. register.tsx draws from these.
5 */
6import type { ConsoleActivity, ConsolePlan, ConsoleScore, ConsoleState } from '../../types'
7
8export type RailStep = 'discover' | 'plan' | 'gate' | 'implement' | 'complete'
9
10export const RAIL: ReadonlyArray<{ step: RailStep; label: string }> = [
11 { step: 'discover', label: 'Discover' },
12 { step: 'plan', label: 'Plan' },
13 { step: 'gate', label: 'Approval' },
14 { step: 'implement', label: 'Implement' },
15 { step: 'complete', label: 'Done' },
16]
17
18/** Starlight amber: reached stars, done links, and the mark on a file being written. */
19export const STAR = '#E0A43A'
20const SLATE = '#5B6577'
21const LABEL = '#8B93A1'
22
23// Star positions on a 300 x 70 chart: an uneven arc, so the rail reads as a constellation.
24const STAR_X = [32, 91, 150, 209, 268]
25const STAR_Y = [34, 20, 30, 14, 26]
26
27/**
28 * The phase rail as a small star chart (an SVG document). Stars before `at`
29 * are reached, the star at `at` carries a halo, later ones are hollow; the
30 * approval star is a diamond. `at` is -1 when there is no phase yet.
31 */
32export function constellationSvg(at: number): string {
33 const parts: string[] = []
34 for (let i = 0; i < STAR_X.length - 1; i++) {
35 const done = i < at
36 parts.push(
37 `<line x1="${STAR_X[i]}" y1="${STAR_Y[i]}" x2="${STAR_X[i + 1]}" y2="${STAR_Y[i + 1]}" stroke="${done ? STAR : SLATE}" stroke-width="${done ? 1.6 : 1}"${done ? '' : ' stroke-dasharray="3 3"'} stroke-linecap="round"/>`,
38 )
39 }
40 RAIL.forEach((r, i) => {
41 const x = STAR_X[i] ?? 0
42 const y = STAR_Y[i] ?? 0
43 const reached = i <= at
44 if (i === at) parts.push(`<circle cx="${x}" cy="${y}" r="10" fill="${STAR}" fill-opacity="0.18"/>`)
45 const size = i === at ? 5 : 3.5
46 const paint = reached ? `fill="${STAR}"` : `fill="none" stroke="${SLATE}" stroke-width="1.2"`
47 parts.push(r.step === 'gate'
48 ? `<rect x="${x - size}" y="${y - size}" width="${size * 2}" height="${size * 2}" transform="rotate(45 ${x} ${y})" ${paint}/>`
49 : `<circle cx="${x}" cy="${y}" r="${size}" ${paint}/>`)
50 parts.push(
51 `<text x="${x}" y="62" text-anchor="middle" font-family="-apple-system, system-ui, sans-serif" font-size="10.5" fill="${i === at ? STAR : LABEL}"${i === at ? ' font-weight="600"' : ''}>${r.label}</text>`,
52 )
53 })
54 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 70" width="300" height="70">${parts.join('')}</svg>`
55}
56
57/** The phase rail for surfaces without Svg, one glyph per star: reached ●, current ◉, ahead ○, approval ◆/◇. */
58export function railGlyphs(at: number): Array<{ glyph: string; label: string; state: 'reached' | 'current' | 'ahead' }> {
59 return RAIL.map((r, i) => {
60 const state = i < at ? 'reached' : i === at ? 'current' : 'ahead'
61 const glyph = r.step === 'gate' ? (state === 'ahead' ? '◇' : '◆') : state === 'current' ? '◉' : state === 'reached' ? '●' : '○'
62 return { glyph, label: r.label, state }
63 })
64}
65
66/** A session dir's name as a title: `2026-10-10_PROJ-1_Add-Auth` reads `Add Auth`. */
67export function sessionTitle(dir: string): string {
68 const name = dir.split('/').pop() ?? dir
69 const slug = name.replace(/^\d{4}-\d{2}-\d{2}_/, '').replace(/^[A-Z][A-Z0-9]+-\d+_/, '')
70 return slug.replace(/[-_]+/g, ' ').trim() || name
71}
72
73/** One plain sentence saying where the work stands. */
74export function statusSentence(s: ConsoleState): string {
75 const name = (p: ConsolePlan | null) => p?.title ?? p?.path.split('/').pop() ?? 'the plan'
76 switch (currentStep(s)) {
77 case 'discover': return 'Discovering. Findings go in DISCOVERY.md.'
78 case 'plan': return `Planning ${name(leadPlan(s))}.`
79 case 'gate': return `Waiting for your approval on ${name(gatedPlan(s))}.`
80 case 'implement': return `Implementing ${name(leadPlan(s))}.`
81 case 'complete': return 'All plans are complete. Wrap up the branch when ready.'
82 default: return 'No phase yet.'
83 }
84}
85
86/** True while a plan still takes answers: drafted, being planned, or awaiting approval. */
87export function isOpenPlan(p: ConsolePlan): boolean {
88 return ['draft', 'planning', 'plan', 'awaiting-approval'].includes(String(p.status ?? '').toLowerCase())
89}
90
91export type DeliveryRow = { key: string; repoPath: string; repo: string; branch: string; base: string; prs: number }
92
93/** Every plan's delivery items, once each: repo path and name, branch, base and PR count. */
94export function deliveryRows(s: ConsoleState): DeliveryRow[] {
95 const seen = new Set<string>()
96 const rows: DeliveryRow[] = []
97 for (const p of s.plans) {
98 for (const d of p.delivery) {
99 const key = `${d.repo ?? ''}|${d.branch ?? ''}|${d.base ?? ''}`
100 if (seen.has(key)) continue
101 seen.add(key)
102 const repoPath = d.repo ?? ''
103 rows.push({ key, repoPath, repo: repoPath.split('/').pop() ?? '', branch: d.branch ?? '?', base: d.base ?? '?', prs: d.prs ?? 0 })
104 }
105 }
106 return rows
107}
108
109/** Where a branch's PR lookup is kept: one entry per repo and branch. */
110export function prKey(repoPath: string, branch: string): string {
111 return `${repoPath}|${branch}`
112}
113
114/** The rail step to highlight: the gate while a plan awaits approval, else the phase. */
115export function currentStep(s: ConsoleState | null): RailStep | null {
116 if (!s || !s.sessionDir || !s.phase) return null
117 return s.gate ? 'gate' : s.phase
118}
119
120/** The plan that sets the phase: state.js lists plans least advanced first. */
121export function leadPlan(s: ConsoleState): ConsolePlan | null {
122 return s.plans[0] ?? null
123}
124
125/** The first plan waiting for approval; the gate buttons act on it. */
126export function gatedPlan(s: ConsoleState): ConsolePlan | null {
127 return s.plans.find(p => String(p.status ?? '').toLowerCase() === 'awaiting-approval') ?? null
128}
129
130/**
131 * One pane button. `submit` sends `text` as the next prompt; `fill` puts it in
132 * the composer so the person finishes the reply that card.py records.
133 */
134export type Action = { key: string; label: string; hotkey: string; kind: 'submit' | 'fill'; text: string }
135
136export function actions(s: ConsoleState | null): Action[] {
137 const step = currentStep(s)
138 if (!s || !step) return []
139 const lead = leadPlan(s)?.path ?? 'the plan'
140 switch (step) {
141 case 'discover':
142 return [{ key: 'discovery', label: 'Write discovery', hotkey: '1', kind: 'submit', text: 'Continue discovery and update DISCOVERY.md in the session dir.' }]
143 case 'plan':
144 return [
145 { key: 'validate', label: 'Validate plan', hotkey: '1', kind: 'submit', text: `Run constellation:plan-validator on ${lead}.` },
146 { key: 'render', label: 'Render card', hotkey: '2', kind: 'submit', text: `Render the approval card for ${lead} with card.py render.` },
147 ]
148 case 'gate':
149 return [
150 { key: 'approve', label: 'Approve', hotkey: '1', kind: 'fill', text: 'approve' },
151 { key: 'changes', label: 'Request changes', hotkey: '2', kind: 'fill', text: 'changes: ' },
152 ]
153 case 'implement':
154 return [
155 { key: 'continue', label: 'Continue', hotkey: '1', kind: 'submit', text: `Continue implementing ${lead}.` },
156 { key: 'handoff', label: 'Save handoff', hotkey: '2', kind: 'submit', text: 'Use constellation:session-handoff to save a handoff.' },
157 ]
158 case 'complete':
159 return [{ key: 'wrapup', label: 'Wrap up branch', hotkey: '1', kind: 'submit', text: 'Use constellation:git-workflow to finish this branch and open the PR.' }]
160 }
161}
162
163/** `now` under a minute, then whole minutes, hours or days. */
164export function age(nowMs: number, mtimeMs: number): string {
165 const s = Math.max(0, Math.floor((nowMs - mtimeMs) / 1000))
166 if (s < 60) return 'now'
167 if (s < 3600) return `${Math.floor(s / 60)}m`
168 if (s < 86400) return `${Math.floor(s / 3600)}h`
169 return `${Math.floor(s / 86400)}d`
170}
171
172export type Warning = { text: string; level: 'warning' | 'error' }
173
174/**
175 * A yellow warning per delivery item whose repo is the one the session is in
176 * but on another branch; a red one when implementing on main or master.
177 */
178export function branchWarnings(s: ConsoleState): Warning[] {
179 const git = s.git
180 if (!git || !git.branch) return []
181 const out: Warning[] = []
182 for (const plan of s.plans) {
183 for (const d of plan.delivery) {
184 if (d.repo === git.toplevel && d.branch && d.branch !== git.branch) {
185 out.push({ text: `⚠ on ${git.branch}; plan expects ${d.branch}`, level: 'warning' })
186 }
187 }
188 }
189 if (s.phase === 'implement' && (git.branch === 'main' || git.branch === 'master')) {
190 out.push({ text: `⚠ on ${git.branch}`, level: 'error' })
191 }
192 return out
193}
194
195export function statusLine(s: ConsoleState | null): string {
196 if (!s || !s.sessionDir) return '✦ constellation · no session yet'
197 return `✦ ${s.phase ?? 'no phase yet'}${s.gate ? ' · ◆ awaiting approval' : ''}`
198}
199
200/** True when `file` is `dir` itself or inside it. */
201export function isUnder(file: string, dir: string): boolean {
202 return file === dir || file.startsWith(dir.endsWith('/') ? dir : `${dir}/`)
203}
204
205/** The score cache after a refresh: every plan that came back with a score. */
206export function nextScores(s: ConsoleState): Record<string, ConsoleScore> {
207 const out: Record<string, ConsoleScore> = {}
208 for (const p of s.plans) {
209 if (typeof p.score === 'number') out[p.path] = { mtimeMs: p.mtimeMs, score: p.score, passed: p.passed === true }
210 }
211 return out
212}
213
214export const ACTIVITY_MS = 45_000
215
216/** Adds a write, newest first: one entry per path, at most 6, none older than 45 s. */
217export function pushActivity(list: ConsoleActivity[], path: string, at: number): ConsoleActivity[] {
218 return [{ path, at }, ...list.filter(a => a.path !== path && at - a.at < ACTIVITY_MS)].slice(0, 6)
219}
220
221/** Adds a skill name, newest first, once each, at most 8. */
222export function pushSkill(list: string[], skill: string): string[] {
223 return [skill, ...list.filter(s => s !== skill)].slice(0, 8)
224}
225
226/** The first line of a message, or '' for none. */
227export function firstLine(text: string): string {
228 return (text.split('\n').find(l => l.trim() !== '') ?? '').trim()
229}
230types/index.d.ts 73 lines1/**
2 * Types of the constellation console mod (hooks/console/): the JSON that
3 * scripts/state.js prints, and the session state the pane draws from.
4 */
5
6export type ConsolePhase = 'discover' | 'plan' | 'implement' | 'complete'
7
8export type ConsoleNeedsCall = { n: number; text: string; default: string }
9
10/** One `delivery` item of a PLAN's frontmatter; a key the plan left out is absent. */
11export type ConsoleDelivery = {
12 repo?: string
13 mode?: string
14 branch?: string
15 base?: string
16 remote?: string
17 prs?: number
18}
19
20export type ConsolePlan = {
21 path: string
22 mtimeMs: number
23 title: string | null
24 status: string | null
25 phase: ConsolePhase
26 score: number | null
27 passed: boolean | null
28 size: string | null
29 needsCall: ConsoleNeedsCall[]
30 delivery: ConsoleDelivery[]
31}
32
33export type ConsoleDoc = { name: string; mtimeMs: number }
34
35export type ConsoleState = {
36 root: string
37 sessionDir: string | null
38 phase: ConsolePhase | null
39 gate: 'awaiting-approval' | null
40 plans: ConsolePlan[]
41 docs: ConsoleDoc[]
42 git: { toplevel: string; branch: string | null } | null
43}
44
45/** A validator score the mod keeps so state.js only re-scores a plan that changed. */
46export type ConsoleScore = { mtimeMs: number; score: number; passed: boolean }
47
48/** A file Claude wrote under the session dir, and when (epoch ms). */
49export type ConsoleActivity = { path: string; at: number }
50
51/** A file shown inside the pane: its text, or why it could not be read. */
52export type ConsolePreview = { name: string; path: string; text: string | null; error: string | null }
53
54/** A branch's pull request as `gh pr list` reports it. */
55export type ConsolePr = { number: number; url: string; state: string }
56
57/** One branch's PR lookup: the PR (null when there is none) and when it was looked up (epoch ms). */
58export type ConsolePrLookup = { pr: ConsolePr | null; at: number }
59
60declare module 'claude-code' {
61 interface PluginState {
62 constellation: {
63 snapshot: ConsoleState | null
64 error: string | null
65 scores: Record<string, ConsoleScore>
66 activity: ConsoleActivity[]
67 skills: string[]
68 preview: ConsolePreview | null
69 prs: Record<string, ConsolePrLookup>
70 }
71 }
72}
73