SLOPSHOPPER

constellation

Curated skills that work together for a complete dev workflow

paneguardcommandstatuspromptprocess
v0.6.0MITupdated 2026-10-12TrevorEdris/constellation
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · constellation
│ ┃ Constellation ✕ › fix the failing auth test and add an audit log call │ ┃ Couldn't read session state: JSON Parse │ ┃ error: Unexpected identifier "dev" ⏺ Read(src/auth.ts) │ ┃ No session journal yet. ⎿ Read 6 lines │ ┃ It starts when this turn ends. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /constellation │ ⎿ constellation: Constellation pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ constellation: ✦ constellation · no session yet

Draws

Pane · Constellation
Couldn't read session state: JSON Parse error: Unexpected identifier "dev" No session journal yet. It starts when this turn ends.
README

Constellation

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.

What you get

  • Discipline that holds under pressure — TDD, systematic debugging, and verification skills with bright-line rules, rationalization tables, and verification gates, so steps don't get skipped.
  • A spec-driven delivery loop — PRD → roadmap → plan (scored gate) → TDD build → review (code / security / chaos) → ship.
  • Parallel execution — orchestration and subagent-driven development for fan-out work.
  • Automated session docs — .ai/sessions/ scaffolding named automatically from Claude Code's own session title, pre-compact snapshots, and handoffs, maintained by hooks.
  • One portable source for Claude Code and Codex.

Layout

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

Install

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.

Console pane

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.

Adding a skill

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.

Design principles

  1. One portable SKILL.md per skill — no per-platform forks.
  2. description = WHEN to trigger, never a workflow summary.
  3. Discipline skills carry an Iron Law + rationalization table + verification gate.
  4. CC-only machinery (hooks, subagents) degrades gracefully on Codex.

Credits

Builds on ideas from superpowers (skill-effectiveness discipline) and fellowship-of-the-workflows (spec-driven planning and session automation).

Source 3 files
hooks/console/register.tsx 380 lines
1/**
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}
380
hooks/console/view.ts 230 lines
1/**
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}
230
types/index.d.ts 73 lines
1/**
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