An always-on engineering operating manual plus spec-workflow skills and templates. Org standards centralized so every product repo picks up changes via /plugin…

Plugin-local decisions for plugins/steer/. Repo-wide rules live in the root CLAUDE.md. (Kept as a README, not a plugin-root CLAUDE.md: a plugin-root CLAUDE.md is flagged by claude plugin validate because it is not loaded as plugin-consumer context - and these are maintainer notes, not shipped context. Ship context to consumers via skills.)
.claude-plugin/plugin.json)defaultEnabled is intentionally omitted - do not add defaultEnabled: false. steer is an org-wide standards plugin; its value is that every product session picks it up automatically. Claude Code's default for an installed plugin is already enabled, so omitting the field gives the behavior we want, and it does so on every CLI version rather than only on those new enough to read defaultEnabled (≥ 2.1.154). Generic "make plugins opt-in" advice does not apply here.displayName ("Steer - Engineering Standards") is the human label in the /plugin picker; the kebab name: steer stays the invocation prefix (/steer:*). Requires Claude Code ≥ 2.1.143 (satisfied by the current pin).reference, audit, standards, next, doctor, explain, status, help, report - never edit code, spec or tracker. What defines the tier is disallowed-tools: Edit, NotebookEdit, EnterWorktree: for the invoking turn the skill has no in-place edit tool and cannot open a worktree. Branching and committing are Bash, which the frontmatter does not withhold. Write splits the tier. standards, next, doctor and reference disallow it too. The five temp-writing skills - audit, explain, help, status, report - deliberately keep Write: the artifact HTML for the four render skills (the Markdown fallback is printed inline, never saved), the scrubbed issue body for report. For explain, help, status and report a single temp-dir path is the one permitted write; audit is the exception - besides its temp artifact and triage export it may write /spec/AUDIT-REPORT.md and /spec/DRIFT-REPORT.md under the repo tree, on confirmation (see its own Write contract). Those limits are held in prose, not by frontmatter. explain additionally disallows Bash (it reads only local files, so it runs no shell); status keeps Bash, because it reads the tracker through tracker-sync (the gh read fallback needs shell), but writes nothing back. This does not make the repo immutable - Bash mutations remain governed by permissions/hooks. If preventive shell enforcement is ever needed, add a PreToolUse hook, not a Stop hook (Stop is detective).allowed-tools grants without restricting (every tool stays callable; permission settings govern the rest), and a disallowed-tools restriction clears at the user's next message. So never disallow Write on the theory that it gates a confirmed write: it buys no safety. Writes the modes instruct (e.g. /steer:audit spec's optional /spec/DRIFT-REPORT.md) happen in-run, post-confirmation. Publication to the tracker is a genuinely separate step because it is a different skill: /steer:work issues publish-*. See /steer:reference artifacts.hooks/register.tsx 295 lines1// steer mod - the status band above the prompt, its detail pane, and
2// /steer_snapshot. Every count and item comes from scripts/workspace-snapshot.sh;
3// this module only draws. It enforces nothing: a press fills the prompt and
4// never submits, so every gate stays the human's Enter and the skill's own.
5// The band shows only steer state - nothing the status line or prompt hint
6// already carries (cwd, branch, model, context, PR).
7import type { EngineInterface, Register } from 'claude-code'
8
9export type Brief = Record<string, string>
10export type Section = 'features' | 'questions' | 'adrs' | 'claims'
11export type BandPart = { text: string; section?: Section; command?: string; key?: string }
12export type Items = {
13 features: { id: string; status: string }[]
14 questions: { scope: string; id: string; status: string; impact: string; before: string; title: string }[]
15 adrs: { n: string; title: string }[]
16 claims: { issue: string; branch: string }[]
17}
18
19const QUIET_SPINES = new Set(['unmanaged', 'foreign'])
20const PANE = 'steer'
21const SECTIONS: { section: Section; label: string }[] = [
22 { section: 'features', label: 'Features' },
23 { section: 'questions', label: 'Open questions' },
24 { section: 'adrs', label: 'ADRs to ratify' },
25 { section: 'claims', label: 'Work claims' },
26]
27
28export function parseBrief(stdout: string): Brief {
29 const brief: Brief = {}
30 for (const line of stdout.split('\n')) {
31 const at = line.indexOf('=')
32 if (at > 0) brief[line.slice(0, at)] = line.slice(at + 1)
33 }
34 return brief
35}
36
37const dash = (v: string | undefined) => (v === undefined || v === '-' ? '' : v)
38
39export function parseItems(stdout: string): Items {
40 const items: Items = { features: [], questions: [], adrs: [], claims: [] }
41 for (const line of stdout.split('\n')) {
42 const [kind, a = '', b = '', c = '', d, e, f] = line.split('\t')
43 if (kind === 'F') items.features.push({ id: a, status: b })
44 if (kind === 'Q')
45 items.questions.push({ scope: a, id: b, status: c, impact: dash(d), before: dash(e), title: dash(f) })
46 if (kind === 'A') items.adrs.push({ n: a, title: b })
47 if (kind === 'C') items.claims.push({ issue: a, branch: b })
48 }
49 items.questions.sort((x, y) => Number(y.impact === 'blocking') - Number(x.impact === 'blocking'))
50 return items
51}
52
53function count(n: string | undefined, one: string, many: string): string | null {
54 const v = Number(n ?? 0)
55 return v > 0 ? `${v} ${v === 1 ? one : many}` : null
56}
57
58export function bandParts(b: Brief): BandPart[] | null {
59 if (b.mode === 'advisory')
60 return [
61 { text: 'steer' },
62 { text: 'advisory' },
63 { text: 'leave advisory', command: '/steer:setup advisory off', key: 'advisory' },
64 ]
65 if (!b.spine) return null
66 if (QUIET_SPINES.has(b.spine))
67 return [{ text: 'steer' }, { text: 'advisory mode', command: '/steer:setup advisory', key: 'advisory' }]
68 const drafts = Number(b.drafts ?? 0)
69 const features = count(b.features, 'feature', 'features')
70 const parts: (BandPart | null)[] = [
71 { text: 'steer' },
72 b.delivery ? { text: b.delivery } : null,
73 b.spine === 'managed' ? null : { text: `spine: ${b.spine}` },
74 part(drafts > 0 && features ? `${features} (${count(b.drafts, 'draft', 'drafts')})` : features, {
75 section: 'features',
76 }),
77 part(count(b.questions, 'open question', 'open questions'), { section: 'questions' }),
78 part(count(b.proposed_adrs, 'ADR to ratify', 'ADRs to ratify'), { section: 'adrs' }),
79 part(count(b.claims, 'work claim', 'work claims'), { section: 'claims' }),
80 // report is model-only (user-invocable: false), so the fill is an ask Claude routes, not a slash command.
81 part(count(b.faults, 'steer fault -> report it', 'steer faults -> report them'), {
82 command: 'Report the unfiled steer fault upstream.',
83 key: 'report',
84 }),
85 ]
86 return parts.filter((p): p is BandPart => p !== null)
87}
88
89function part(text: string | null, rest: Omit<BandPart, 'text'>): BandPart | null {
90 return text === null ? null : { text, ...rest }
91}
92
93export function bandText(b: Brief): string | null {
94 return bandParts(b)?.map(p => p.text).join(' - ') ?? null
95}
96
97// What a row's button puts in the prompt: the skill that owns that item.
98export function featureCommand(f: Items['features'][number]): string {
99 return f.status === 'draft' ? `/steer:spec ${f.id}` : `/steer:status feature ${f.id}`
100}
101
102export function questionCommand(q: Items['questions'][number]): string {
103 return q.scope === 'vision' ? '/steer:spec questions' : `/steer:spec ${q.scope}`
104}
105
106// cwd: the directory the script resolves its repo root from; absent, the
107// session's working directory.
108function snapshot($: EngineInterface, args: string[], cwd?: string) {
109 return $.process.run(['sh', `${$.plugin.root}/scripts/workspace-snapshot.sh`, ...args], {
110 cwd,
111 env: { CLAUDE_PLUGIN_ROOT: $.plugin.root },
112 timeoutMs: 10_000,
113 })
114}
115
116let parts: BandPart[] | null = null
117let items: Items | null = null
118let shown: Section = 'questions'
119let lastCwd: string | undefined
120
121// A snapshot that fails or cannot start hides the band; it never rejects, so
122// the fire-and-forget callers below leave no unhandled rejection.
123async function refresh($: EngineInterface, cwd?: string) {
124 if (cwd !== undefined) lastCwd = cwd
125 let fresh: BandPart[] | null = null
126 try {
127 const run = await snapshot($, ['--brief'], lastCwd)
128 fresh = run.exitCode === 0 ? bandParts(parseBrief(run.stdout)) : null
129 } catch {
130 fresh = null
131 }
132 if (JSON.stringify(fresh) !== JSON.stringify(parts)) {
133 parts = fresh
134 $.ui.invalidate('ui.render')
135 }
136 const panes = await $.ui.panes().catch(() => [])
137 if (panes.some(p => p.id === PANE)) await loadItems($)
138}
139
140async function loadItems($: EngineInterface) {
141 let fresh: Items | null = null
142 try {
143 const run = await snapshot($, ['--items'], lastCwd)
144 fresh = run.exitCode === 0 ? parseItems(run.stdout) : null
145 } catch {
146 fresh = null
147 }
148 items = fresh
149 $.ui.invalidate('ui.render')
150}
151
152async function show($: EngineInterface, section: Section) {
153 shown = section
154 $.ui.invalidate('ui.render')
155 await $.ui.open({ id: PANE, title: 'steer', focus: true, closeOnEscape: true })
156 await loadItems($)
157}
158
159// The pane holds the keys while open, and a box under a dialog refuses a fill.
160async function suggest($: EngineInterface, text: string) {
161 await $.ui.close({ id: PANE })
162 await $.prompt.fill({ text, mode: 'replace' })
163}
164
165export const register: Register = on => {
166 on('session.start', async ($, e, next) => {
167 void refresh($, e.cwd)
168 await $.command.register({
169 name: 'steer_snapshot',
170 description: 'Print the steer workspace snapshot now, without a Claude turn',
171 immediate: true,
172 })
173 return next(e)
174 })
175
176 on('turn.complete', async ($, e, next) => {
177 void refresh($)
178 return next(e)
179 })
180
181 // /cd or a worktree move: re-read the new directory's repo now, not at the
182 // end of the next turn. CwdChanged also runs steer's shell hook, so a
183 // failure here passes the event on untouched.
184 on('classic.CwdChanged', async ($, e, next) => {
185 void refresh($, e.new_cwd)
186 return next(e)
187 }).catch(($, e, next) => next(e))
188
189 on('command.run', { command: 'steer_snapshot' }, async $ => {
190 try {
191 const run = await snapshot($, [], lastCwd)
192 return { text: run.exitCode === 0 ? run.stdout : `steer snapshot failed: ${run.stderr}` }
193 } catch (err) {
194 return { text: `steer snapshot failed: ${err instanceof Error ? err.message : String(err)}` }
195 }
196 })
197
198 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
199 if (parts === null || e.props.hasSurvey) return next(e)
200 const { Box, Button, Text } = $.ui.resolve(e)
201 return (
202 <Box flexDirection="row" flexWrap="wrap">
203 {parts.flatMap((p, i) => {
204 const sep = i > 0 ? [<Text dimColor> - </Text>] : []
205 const { section, command, key } = p
206 if (section)
207 return [
208 ...sep,
209 <Button
210 key={`band-${section}`}
211 label={p.text}
212 plain
213 dimColor
214 onPress={() => show($, section)}
215 />,
216 ]
217 if (command)
218 return [
219 ...sep,
220 <Button key={`band-${key}`} label={p.text} plain dimColor onPress={() => suggest($, command)} />,
221 ]
222 return [...sep, <Text dimColor>{p.text}</Text>]
223 })}
224 </Box>
225 )
226 })
227
228 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
229 const { Box, Button, Text } = $.ui.resolve(e)
230 const row = (key: string, label: string, command: string, text: string) => (
231 <Box key={key} flexDirection="row" gap={1}>
232 <Button key={`do-${key}`} label={label} onPress={() => suggest($, command)} />
233 <Text wrap="truncate-end">{text}</Text>
234 </Box>
235 )
236 const sizes = items && {
237 features: items.features.length,
238 questions: items.questions.length,
239 adrs: items.adrs.length,
240 claims: items.claims.length,
241 }
242 const body = () => {
243 if (items === null) return [<Text dimColor>Reading the spine...</Text>]
244 if (sizes?.[shown] === 0) return [<Text dimColor>Nothing here.</Text>]
245 if (shown === 'features')
246 return items.features.map((f, i) =>
247 row(`f-${i}`, f.status === 'draft' ? 'shape' : 'view', featureCommand(f), `${f.id} ${f.status}`),
248 )
249 if (shown === 'questions')
250 return [
251 ...items.questions.map((q, i) =>
252 row(
253 `q-${i}`,
254 'resolve',
255 questionCommand(q),
256 [q.id, q.scope, q.impact, q.before && `before: ${q.before}`, q.title].filter(Boolean).join(' '),
257 ),
258 ),
259 <Box key="q-all" flexDirection="row" gap={1} marginTop={1}>
260 <Button key="sweep" label="sweep all" onPress={() => suggest($, '/steer:spec questions')} />
261 <Button key="bundle" label="PO questionnaire" onPress={() => suggest($, '/steer:spec questions bundle')} />
262 </Box>,
263 ]
264 if (shown === 'adrs')
265 return items.adrs.map((a, i) => row(`a-${i}`, 'ratify', `/steer:spec adr accept ${a.n}`, a.title || `ADR ${a.n}`))
266 return items.claims.map((c, i) =>
267 row(
268 `c-${i}`,
269 'resume',
270 c.issue ? `/steer:work resume #${c.issue}` : '/steer:work resume',
271 [c.issue && `#${c.issue}`, c.branch].filter(Boolean).join(' '),
272 ),
273 )
274 }
275 return (
276 <Box flexDirection="column">
277 <Box flexDirection="row" flexWrap="wrap" gap={1} marginBottom={1}>
278 {SECTIONS.map(s => (
279 <Button
280 key={`tab-${s.section}`}
281 label={sizes ? `${s.label} ${sizes[s.section]}` : s.label}
282 variant={s.section === shown ? 'primary' : 'secondary'}
283 onPress={() => {
284 shown = s.section
285 $.ui.invalidate('ui.render')
286 }}
287 />
288 ))}
289 </Box>
290 {body()}
291 </Box>
292 )
293 })
294}
295