Band above the prompt: branch, dirty files, ahead/behind, PR, CI and prod deploy for the repo the session works in; toasts when CI finishes or prod goes live.

This repository holds plugins from Tempered Works for AI coding tools. Five plugins are for Claude Code. One plugin is for Codex.
A Claude Code "mod" is a plugin of function hooks. A function hook is code that Claude Code runs when an event occurs, for example when a tool call ends. The five Claude Code plugins here are mods. They run inside Claude Code, in the terminal and in the desktop Code tab.
| Folder | Tool | What it does |
|---|---|---|
ship-state/ | Claude Code | Shows the git, pull request, CI and deploy state of the current repo in one line above the prompt. |
timeline/ | Claude Code | Shows a vertical timeline of the work in a side pane: what you asked, what Claude did, and what each subagent is doing. |
limit-resume/ | Claude Code | Shows your usage limits and continues a turn after a rate limit resets. |
followups/ | Claude Code | Shows 4 options for your next prompt above the prompt box after each answer. You press 1 to 4 to put one in the box. |
lessons/ | Claude Code | Finds wins and pitfalls in your prompts. It then asks Claude to run your own win-logger and pitfall-logger skills. The status line shows how many entries you logged. |
multi-harness/ | Codex | Gives Codex 6 skills to plan large work in waves and to track it to completion. |
.claude-plugin/marketplace.json | Claude Code | Lists the 5 Claude Code plugins so that Claude Code can install them from this repository. |
ship-state. During a coding session, you often need to know if your work is pushed, if CI passed, and if production has the change. Without this plugin, you ask Claude, and Claude runs git and gh commands to find out. Each check costs a model turn. ship-state shows the answer on screen at all times and makes no model calls.
timeline. Long work with many steps and many subagents is hard to follow. Without a record, you ask "what is left?" and "what is the current goal?" many times. You also cannot see which model each subagent uses. timeline keeps one record per repo across sessions and shows it as a timeline.
limit-resume. When a session hits a usage limit, the work stops until you type "try again". If you are away, the session stays idle after the limit resets. limit-resume continues the work at the reset time. It also shows your usage before you reach the limit.
followups. Claude Code shows one grey suggestion for your next prompt. That suggestion is often the wrong one. followups shows 4 options in 4 directions: continue the plan, verify the work, take the alternative path, and wrap up. These options cover the usual next moves. You choose one and edit it. followups sends nothing until you press Enter.
lessons. The same mistakes happen again, and good patterns get lost. A skill that logs them is useful, but it often does not run at the right moment. lessons makes the skill run. It reads your prompts for praise, frustration, and repeated requests. When it finds one, it tells Claude to run the matching skill after Claude finishes your request. The skill always asks "Log it? y/n" before it writes. You stay in control.
multi-harness. Large product work needs a plan, branch and pull request gates, tracker updates, QA evidence, and a safe closeout. multi-harness gives Codex a repeatable method for these steps. The method is the same for every product.
claude plugin validate.git 2.31 or newer.gh, signed in. ship-state and timeline use it for pull request, CI and deploy data. Without gh, they show only local git data.Use one of these 2 methods.
Method 1: install from the marketplace. Run these commands in a Claude Code session:
/plugin marketplace add tyree88/tempered_plugins
/plugin install ship-state@tempered-plugins
/plugin install timeline@tempered-plugins
/plugin install limit-resume@tempered-plugins
/plugin install followups@tempered-plugins
/plugin install lessons@tempered-plugins
Install only the plugins that you want.
Method 2: load the folders directly. Clone this repository. Then add the plugin folders to the env block of ~/.claude/settings.json. Separate the folders with :.
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/tempered_plugins/ship-state:/path/to/tempered_plugins/timeline:/path/to/tempered_plugins/followups:/path/to/tempered_plugins/lessons" } }
New sessions load the plugins. Sessions that are already open do not.
ship-state needs no action. It starts with each session.
app ⎇ feat/waitlist · 3 dirty · ↑2 ↓0 · PR #312 · CI ⏳ 4/5 · prod = HEAD ✓ 2h ago
| Part | Meaning |
|---|---|
3 dirty | 3 files have changes that are not committed. |
↑2 ↓0 | 2 commits are not pushed. 0 commits are not pulled. |
PR #312 | The branch has open pull request 312. |
CI ⏳ 4/5 | 4 of 5 CI checks are complete. |
prod = HEAD ✓ | Production runs the current commit. |
ship-state follows Claude when Claude changes to another repo or worktree. It reads local git data every 20 seconds. It reads GitHub data every 5 minutes. After a push or merge, it reads GitHub data every 20 seconds for 10 minutes.
The band of ship-state stacks with the bands of other plugins, such as followups.
/timeline to open or close the pane. The pane also opens by itself when Claude logs the first task of a session.██████░░░░ 2/3, how Claude did the step, and the next step.↳ show commits, pushes, pull requests and CI results for the active task.now:), the next step (next:), the number of tool calls, the tokens, and the result.⚠ no model set: inherited on a subagent card. This warning means that the subagent uses the same model as the main session, because nothing set a model for it.◀ older and newer ▶ to move between pages.How timeline works:
mcp__timeline__log. It also adds an instruction of about 100 tokens that tells Claude when to log. Claude logs once for each change of direction, and once at the start, each step, the end, or a block of each task.~/.claude/timelines/<repo>-<hash>/. Each session writes only its own files. Worktrees of a repo share one timeline.Usage cost: each logged entry costs approximately 40 to 80 output tokens. The instruction costs approximately 100 tokens in each session.
Privacy: the timeline files contain Claude's one-line summaries, the first 2000 characters of each subagent prompt, and commit subjects. The files stay on your computer.
Status: timeline is built and reviewed. Live testing is in progress.
followups needs no action. It starts with each session.
1: … to 4: ….1 to 4 when the prompt box is empty. You can also click an option. The text of the option goes into the prompt box./followups off or /followups on. Type /followups status to see the current setting and the last error, for example a refused model call.When the band shows, a digit that you type in an empty prompt box picks an option. To start a message with a digit, type a space first.
The band hides while the prompt box has text, while a turn runs, and while a survey uses the band. It comes back when the prompt box is empty.
followups hides the built-in grey suggestion of Claude Code, but only after its own band has drawn once. In a surface without the band, the built-in suggestion stays.
followups makes no options for subagent turns, interrupted turns, errors, and empty answers. If you send a prompt before Haiku answers, followups drops the old reply. After /clear or a resume, followups removes the old options.
Usage cost: followups makes one Haiku call for each answered turn. A call costs approximately 2,000 input tokens and 150 output tokens. followups adds nothing to the context of the main model.
Privacy: the first 1,500 characters of your last prompt and the last 4,000 characters of the answer go to Haiku. The call uses the API client of Claude Code.
lessons needs 2 skills of your own. Name them win-logger and pitfall-logger. This repository does not include them. The skills decide where an entry goes, for example a Notion database. Each skill always asks "Log it? y/n" before it writes.
y to log it. Answer n to skip it.🌱 2 · ⚠ 1 means 2 wins and 1 pitfall are logged in this session. lessons counts a row when you answer y to a draft. The count resets after /clear or a resume./lessons off or /lessons on. Type /lessons status to see the setting, the counts, and where each skill was found.| Kind | Signals |
|---|---|
| Win | Praise, for example "perfect", "nailed it", "love this", "this is great", "exactly what I wanted". Or an ask: "log this win", "log this as a win", "add this to learnings", "remember this worked". |
| Pitfall | Frustration, for example "I already told you", "no, I said", "still wrong", "still failing", "this is the third time", "why did you change…", "not what I asked". Or shouting: several words in all capitals. Or the same request sent again: it has high word overlap with one of your last 10 prompts. Or an ask: "log this", "add this to pitfalls", "remember this lesson". |
lessons avoids common false signals. These prompts do not trigger it: "exactly 3 retries", "pixel perfect", "log this error to sentry", "why did you choose zod?", "the second time I click it throws". File names such as README or CHANGELOG do not trigger it. HTTP method names do not trigger it.
lessons adds at most 1 note of each kind for each 5 prompts.
lessons looks for the skills in 2 places:
anthropic-skills:<name>.If a skill is missing, lessons shows a toast at most once a day. /lessons status shows "missing" for that skill.
Usage cost: lessons makes no model calls. A note costs approximately 40 tokens. lessons adds a note only to a prompt that matches.
Privacy: your last 10 prompts stay in memory only, for the repeat check. lessons does not write them to disk.
limit-resume needs no action. It starts with each session.
5h 62% · 7d 41%. 5h is the 5-hour window. 7d is the 7-day window./autoresume off or /autoresume on. Type /autoresume status to see the current setting.Claude Code has a built-in setting, autoContinueAtUsageLimit, that also continues after a usage limit. Do not use the built-in setting and limit-resume together for usage limits. If you do, the turn gets 2 "continue" messages.
multi-harness is a Codex plugin. Its manifest is multi-harness/.codex-plugin/plugin.json, and its plugin name is platform-orchestrator. It is not in the Claude Code marketplace file.
multi-harness folder with the Codex plugin installer.multi-harness/assets/templates/ for wave plans, tracker updates, QA evidence, and pull request closeout.The 6 skills are:
| Skill | Use |
|---|---|
platform-wave-orchestrator | Divide a backlog into agent lanes and implementation waves. |
platform-agent-patterns | Choose how agents work together on a wave. |
platform-pr-closeout | Gate and close branches and pull requests. |
platform-tracker-sync | Update GitHub and Notion trackers. |
platform-qa-evidence | Collect QA evidence for each change. |
platform-safety-review | Review work that touches sensitive data, regulated text, or trust and safety limits. |
To check the folder structure, run python3 multi-harness/scripts/check_plugin_structure.py.
Each Claude Code plugin has checks for its logic. Node 23 or newer runs the .ts check files directly.
node limit-resume/check.ts
node ship-state/check.ts
node followups/checks/ask.check.ts
node lessons/checks/detect.check.ts
bash timeline/checks/run.sh
To run the behavior test of followups, run claude plugin test followups. It runs 8 cases on the terminal and desktop surfaces.
To run the behavior test of lessons, run claude plugin test lessons. It runs 9 cases.
To type-check timeline, do these 2 steps:
/plugin-types .claude/types. This command writes the Claude Code type declarations.tsc -p timeline/tsconfig.check.json.MIT. See LICENSE. Copyright 2026 Tempered Works LLC.
hooks/register.tsx 168 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type { Snap } from '../types'
5import { isPending, lastCd, segments, summarize } from './lib'
6
7const snap = atom({ plugin: 'ship-state', key: 'snap' } as const, null)
8
9const TICK = 20_000 // local git every tick; remote too while CI or a deploy is pending
10const REMOTE_EVERY = 5 * 60_000
11const WATCH = 10 * 60_000 // after a push/merge, poll remote fast for this long
12const PUSH = /\bgit\s+push\b|\bgh\s+pr\s+(merge|create)\b|\bvercel\b.*--prod/
13const TONE = { dim: undefined, ok: 'green', warn: 'yellow', bad: 'red' } as const
14
15const parse = (text: string | undefined) => {
16 try {
17 return text ? JSON.parse(text) : undefined
18 } catch {
19 return undefined
20 }
21}
22
23// Shared by the hooks and the top-level helpers below: the loader lets $ reach only functions declared at the top of this file.
24const st = {
25 dir: undefined as string | undefined,
26 cur: null as Snap | null,
27 gh: 'gh',
28 remoteAt: 0,
29 watchUntil: 0,
30 isBusy: false,
31 seen: { ciPending: '', prod: '' },
32}
33
34const run = async ($: Engine, argv: string[], cwd?: string) => {
35 try {
36 const r = await $.process.run(argv, cwd ? { cwd, timeoutMs: 15_000 } : { timeoutMs: 15_000 })
37 return r.exitCode === 0 ? r.stdout.trim() : undefined
38 } catch {
39 return undefined
40 }
41}
42const toplevel = ($: Engine, path: string) => run($, ['git', '-C', path, 'rev-parse', '--show-toplevel'])
43
44const notify = ($: Engine, s: Snap) => {
45 if (s.ci?.pending) st.seen.ciPending = s.ci.sha
46 else if (s.ci && st.seen.ciPending === s.ci.sha) {
47 st.seen.ciPending = ''
48 const sha = s.ci.sha.slice(0, 7)
49 $.ui.toast(s.ci.failed.length ? `CI ✗ ${s.ci.failed.join(', ')} @${sha}` : `CI ✓ all ${s.ci.total} green @${sha}`, {
50 timeoutMs: 10_000,
51 })
52 }
53 const key = s.prod ? `${s.prod.sha}:${s.prod.state}` : ''
54 if (st.seen.prod && s.prod && key !== st.seen.prod) {
55 const sha = s.prod.sha.slice(0, 7)
56 if (s.prod.state === 'success') $.ui.toast(`Live on prod: ${sha}${s.prod.url ? ` → ${s.prod.url}` : ''}`, { timeoutMs: 15_000 })
57 if (s.prod.state === 'failure' || s.prod.state === 'error') $.ui.toast(`Prod deploy ${s.prod.state}: ${sha}`, { timeoutMs: 15_000 })
58 }
59 st.seen.prod = key
60}
61
62const refresh = async ($: Engine, wantRemote: boolean) => {
63 const d = st.dir // a `cd` can move `dir` mid-refresh; this snapshot stays on one repo
64 if (st.isBusy || !d) return
65 st.isBusy = true
66 const git = (...args: string[]) => run($, ['git', ...args], d)
67 const api = async (path: string) => parse(await run($, [st.gh, 'api', `repos/{owner}/{repo}/${path}`], d))
68 try {
69 const head = await git('rev-parse', 'HEAD')
70 if (!head) {
71 st.cur = null
72 await update($, snap, () => null)
73 return
74 }
75 const branch = (await git('symbolic-ref', '--short', '-q', 'HEAD')) ?? `detached ${head.slice(0, 7)}`
76 const dirty = ((await git('status', '--porcelain')) ?? '').split('\n').filter(Boolean).length
77 const counts = await git('rev-list', '--left-right', '--count', '@{u}...HEAD')
78 const [behind, ahead] = counts ? counts.split(/\s+/).map(Number) : [null, null]
79 const s: Snap = { ...(st.cur?.dir === d ? st.cur : {}), dir: d, branch, head, dirty, ahead, behind }
80
81 const now = await $.clock.now()
82 if (wantRemote || isPending(st.cur) || now < st.watchUntil || now - st.remoteAt > REMOTE_EVERY) {
83 st.remoteAt = now
84 const upstream = await git('rev-parse', '@{u}')
85 const runs = upstream && (await api(`commits/${upstream}/check-runs?per_page=100`))
86 const status = upstream && (await api(`commits/${upstream}/status`))
87 s.ci = upstream && (runs || status)
88 ? { sha: upstream, ...summarize(runs?.check_runs ?? [], status?.statuses ?? []) }
89 : undefined
90 s.pr = branch.startsWith('detached') ? undefined : parse(await run($, [st.gh, 'pr', 'view', '--json', 'number,state'], d))
91 const dep = (await api('deployments?environment=Production&per_page=1'))?.[0]
92 const last = dep && (await api(`deployments/${dep.id}/statuses?per_page=1`))?.[0]
93 s.prod = dep
94 ? { sha: dep.sha, state: last?.state ?? 'pending', url: last?.environment_url, at: last?.created_at ?? dep.created_at }
95 : undefined
96 notify($, s)
97 }
98 st.cur = s
99 await update($, snap, () => s)
100 } finally {
101 st.isBusy = false
102 }
103}
104
105export const register: Register = on => {
106 on('session.start', async ($, e, next) => {
107 st.dir = await toplevel($, await $.session.cwd())
108 // The desktop app's PATH may lack Homebrew or /usr/local: try the usual install locations.
109 for (const candidate of ['gh', '/opt/homebrew/bin/gh', '/usr/local/bin/gh']) {
110 if (await run($, [candidate, '--version'], undefined)) {
111 st.gh = candidate
112 break
113 }
114 }
115 void refresh($, true)
116 $.clock.every(TICK, () => void refresh($, false))
117 return next(e)
118 })
119
120 // Follow the repo Claude actually works in (it often `cd`s into a worktree), and watch after a push or merge.
121 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
122 const ran = await next(e)
123 if (e.agentId) return ran
124
125 const target = lastCd(e.command, st.dir ?? (await $.session.cwd()))
126 const top = target && (await toplevel($, target))
127 if (top && top !== st.dir) {
128 st.dir = top
129 st.cur = null
130 st.remoteAt = 0 // new repo: fetch its CI/prod now, not in 5 min
131 st.seen.ciPending = st.seen.prod = ''
132 }
133 const isPushed = PUSH.test(e.command) && ran.deny === undefined && ran.isError !== true
134 if (isPushed) st.watchUntil = (await $.clock.now()) + WATCH
135 if (top || isPushed || /\b(git|gh)\b/.test(e.command)) void refresh($, isPushed)
136 return ran
137 })
138
139 on('turn.complete', ($, e, next) => {
140 if (!e.agentId) void refresh($, false)
141 return next(e)
142 })
143
144 // Stacks: our row, then whatever the plugins beneath drew (followups' options).
145 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
146 const beneath = await next(e)
147 const s = await read($, snap)
148 if (!s || e.props.hasSurvey) return beneath
149
150 const { Box, Text } = $.ui.resolve(e)
151 const now = await $.clock.now()
152
153 return (
154 <Box flexDirection="column">
155 <Box flexDirection="row">
156 {segments(s, now).map((seg, i) => (
157 <Text key={String(i)} color={TONE[seg.tone]} dimColor={seg.tone === 'dim'} wrap="truncate">
158 {i ? ' · ' : ''}
159 {seg.text}
160 </Text>
161 ))}
162 </Box>
163 {beneath}
164 </Box>
165 )
166 })
167}
168hooks/lib.ts 67 lines1import type { Checks, Snap } from '../types'
2
3export type Run = { name: string; status: string; conclusion: string | null; started_at?: string | null }
4export type Status = { context: string; state: string }
5export type Seg = { text: string; tone: 'dim' | 'ok' | 'warn' | 'bad' }
6
7const FAIL = new Set(['failure', 'timed_out', 'action_required', 'error'])
8const WAIT = new Set(['pending', 'queued', 'in_progress'])
9
10// Last `cd <dir>` in a shell command, resolved against base; undefined when there is none.
11export function lastCd(command: string, base: string): string | undefined {
12 const all = [...command.matchAll(/(?:^|&&|;|\|\||\n)\s*cd\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))/g)]
13 const m = all.at(-1)
14 const p = m && (m[1] ?? m[2] ?? m[3])
15 if (!p || /^[~$-]/.test(p)) return undefined
16 return p.startsWith('/') ? p : `${base}/${p}`
17}
18
19// One verdict per check name: the newest non-skipped run wins; commit statuses are already latest per context.
20export function summarize(runs: Run[], statuses: Status[]): Checks {
21 const verdict = new Map<string, string>()
22 const live = runs
23 .filter(r => r.conclusion !== 'skipped' && r.conclusion !== 'neutral')
24 .sort((a, b) => (b.started_at ?? '').localeCompare(a.started_at ?? ''))
25 for (const r of live) {
26 if (!verdict.has(r.name)) verdict.set(r.name, r.status === 'completed' ? (r.conclusion ?? 'neutral') : 'pending')
27 }
28 for (const s of statuses) if (!verdict.has(s.context)) verdict.set(s.context, s.state)
29 const all = [...verdict]
30 return {
31 total: all.length,
32 pending: all.filter(([, v]) => WAIT.has(v)).length,
33 failed: all.filter(([, v]) => FAIL.has(v)).map(([n]) => n),
34 }
35}
36
37export const isPending = (s: Snap | null) =>
38 !!s && ((s.ci?.pending ?? 0) > 0 || WAIT.has(s.prod?.state ?? ''))
39
40const ago = (iso: string, now: number) => {
41 const m = Math.max(0, Math.round((now - Date.parse(iso)) / 60_000))
42 return m < 60 ? `${m}m ago` : m < 1440 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`
43}
44
45export function segments(s: Snap, now: number): Seg[] {
46 const segs: Seg[] = [{ text: `${s.dir.split('/').at(-1)} ⎇ ${s.branch}`, tone: 'dim' }]
47 segs.push(s.dirty ? { text: `${s.dirty} dirty`, tone: 'warn' } : { text: 'clean', tone: 'dim' })
48 if (s.ahead === null) segs.push({ text: 'no upstream', tone: 'warn' })
49 else segs.push({ text: `↑${s.ahead} ↓${s.behind}`, tone: s.ahead || s.behind ? 'warn' : 'dim' })
50 if (s.pr) segs.push({ text: `PR #${s.pr.number}${s.pr.state === 'OPEN' ? '' : ` ${s.pr.state.toLowerCase()}`}`, tone: 'dim' })
51 if (s.ci?.total) {
52 const at = s.ci.sha === s.head ? '' : ` @${s.ci.sha.slice(0, 7)}`
53 if (s.ci.failed.length) segs.push({ text: `CI ✗ ${s.ci.failed.join(', ')}${at}`, tone: 'bad' })
54 else if (s.ci.pending) segs.push({ text: `CI ⏳ ${s.ci.total - s.ci.pending}/${s.ci.total}${at}`, tone: 'warn' })
55 else segs.push({ text: `CI ✓ ${s.ci.total}${at}`, tone: 'ok' })
56 }
57 if (s.prod) {
58 const { sha, state, at } = s.prod
59 const who = sha === s.head ? 'prod = HEAD' : `prod ${sha.slice(0, 7)}`
60 if (state === 'success') segs.push({ text: `${who} ✓ ${ago(at, now)}`, tone: 'ok' })
61 else if (FAIL.has(state)) segs.push({ text: `${who} ✗ ${state}`, tone: 'bad' })
62 else if (WAIT.has(state)) segs.push({ text: `${who} ⏳ deploying`, tone: 'warn' })
63 else segs.push({ text: `${who} ${state}`, tone: 'dim' })
64 }
65 return segs
66}
67types/index.d.ts 20 lines1export type Checks = { total: number; pending: number; failed: string[] }
2
3export type Snap = {
4 dir: string
5 branch: string
6 head: string
7 dirty: number
8 ahead: number | null
9 behind: number | null
10 pr?: { number: number; state: string }
11 ci?: Checks & { sha: string }
12 prod?: { sha: string; state: string; url?: string; at: string }
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'ship-state': { snap: Snap | null }
18 }
19}
20