Display-only guided bootcamp pane with session checkpoints

The Bootcamp pane shows one lesson step beside the Claude Code session. A wide terminal shows a side pane. A narrow terminal shows an inline pane. Claude Code selects the placement. Text wraps to fit the pane.
Run these commands from the ops-fork root:
claude plugin marketplace add ./ --scope local
claude plugin install apexyard-bootcamp@apexyard --scope local
Restart Claude Code or run /reload-plugins after an update.
/bootcamp opens or closes the pane./bootcamp-next shows the next step. It crosses module boundaries./bootcamp-back shows the previous step. It crosses module boundaries./bootcamp-goto <module> <step> selects a step. Numbers start at one./bootcamp-reset returns to the first step and clears all checkpoints.Next and back stop at the course bounds. Goto clamps numbers to valid bounds. Invalid goto arguments leave progress unchanged. Navigation does not require a ticked checkpoint.
The pane shows the module header, progress row, slide text, command, checkpoint, presenter note, and navigation hint. [>] marks the current step. [x] marks a ticked checkpoint. [ ] marks another step. Replace command placeholders with actual ticket and PR numbers.
Create one Markdown file for each module under content/NN-<slug>.md. Two digits define the module order. Use lowercase letters, digits, and hyphens in the slug. Start each file with # Module title. Each ## Step title starts a step. Put body text before optional metadata lines:
# Setup
## Configure ApexYard
Run /setup from the ops fork. Answer the company and tool questions.
Try: /setup
Check: skill setup
Done: Setup call observed.
Note: Explain the company settings.
Try: <command> displays a suggested command or plain request.Check: skill <name> matches a skill prompt or Skill call with that exact name, ignoring a plugin prefix.Check: bash <regex> matches a Bash command with a case-insensitive JavaScript regex.Done: <text> supplies the ticked checkpoint text.Note: <presenter note> displays dim text.Each field can appear once per step. Empty fields and invalid regexes produce an error. Body text is required. The pane shows body text as terminal text, including any Markdown markers. A malformed module shows its file name and parser error. Navigation can move past that module. Use three to six steps for each generic module. Use short active sentences with at most 20 words. Avoid nested quantifiers in content regexes. Commands longer than 32,000 characters do not tick Bash checkpoints. Real PR commands with an inline body reach about 7,000 characters. Use ASCII content and real ApexYard skill names. Keep Corner Shop as the sample project.
The status plugin uses $.fs.read for runtime file reads. This plugin imports bundled Markdown strings. It pairs well with the explainer pane when installed. The runtime makes no file calls. After you edit content, run:
node plugins/apexyard-bootcamp/scripts/build-content.mjs
node plugins/apexyard-bootcamp/scripts/build-content.mjs --check
The build script writes hooks/content.generated.ts. The check fails when that file is stale. Run both the plugin tests and the Node bundle test before you submit a change.
The atom, read, and update helpers keep module, step, and checkpoint state in the plugin store ($.store). They call the state API, as the reference plugins do. The host persists the store. Closing the pane does not clear progress. A restart resumes the stored step. Reset clears stored checkpoints.
Only the current step can tick. Matching events still tick while the pane is closed. A step without Check: never ticks. A plain request alone cannot tick a checkpoint. A checkpoint confirms a matching skill prompt or tool call after the existing handler returns. Denied tool calls do not tick. Skill prompts include typed commands, Skill calls, and subagent preloads. The skill event has no origin field, so a matching preload can tick. It does not prove success, approval, or a completed merge. The plugin passes skill prompts and tool calls to the existing handler. It never replaces a governance hook.
claude plugin validate --strict plugins/apexyard-bootcamp
claude plugin test plugins/apexyard-bootcamp
node --test plugins/apexyard-bootcamp/scripts/content.test.mjs
The runtime displays local content and updates the plugin store only. It makes no model, network, process, or file calls. It writes no files. The build script and Node test use local files outside the runtime. The early-access pane API can change. A pane failure does not change any governance gate.
hooks/register.tsx 71 lines1// Display only. No model, network, process, file calls or hook replacement.
2import { atom, read, update } from 'claude-code'
3import type { Register } from 'claude-code'
4import { checkpointKey, contentError, initial, loadLessons, navigate, normalize, tick } from './bootcamp'
5import { content } from './content.generated'
6
7const PANE = 'apexyard-bootcamp'
8export const lessons = loadLessons(content)
9export const progress = atom({ plugin: 'apexyard-bootcamp', key: 'progress' } as const, initial())
10const commands = {
11 bootcamp: 'Open or close the Bootcamp pane',
12 'bootcamp-next': 'Show the next bootcamp step',
13 'bootcamp-back': 'Show the previous bootcamp step',
14 'bootcamp-goto': 'Show module and step: /bootcamp-goto <module> <step>',
15 'bootcamp-reset': 'Reset bootcamp progress and checkpoints',
16}
17
18export const register: Register = on => {
19 on('session.start', async ($, e, next) => {
20 const result = await next(e)
21 for (const [name, description] of Object.entries(commands)) {
22 try { await $.command.register({ name, description, immediate: true }) } catch {}
23 }
24 return result
25 })
26 for (const command of Object.keys(commands)) {
27 on('command.run', { command }, async ($, e) => {
28 if (command === 'bootcamp') {
29 if ((await $.ui.panes()).some(p => p.id === PANE)) await $.ui.close({ id: PANE })
30 else await $.ui.open({ id: PANE, title: 'Bootcamp', rows: 18 })
31 } else await update($, progress, p => navigate(p, lessons, command, e.args))
32 return {}
33 })
34 }
35 on('tool.call', async ($, e, next) => {
36 const result = await next(e)
37 if (result.deny === undefined) {
38 const event = e.tool === 'Skill' ? { tool: e.tool, skill: e.skill }
39 : e.tool === 'Bash' ? { tool: e.tool, command: e.command } : { tool: e.tool }
40 try { await update($, progress, p => tick(p, lessons, event)) } catch {}
41 }
42 return result
43 })
44 on('skill.prompt', async ($, e, next) => {
45 const result = await next(e)
46 try { await update($, progress, p => tick(p, lessons, { tool: 'Skill', skill: e.skill })) } catch {}
47 return result
48 })
49 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
50 const { Box, Text } = $.ui.resolve(e)
51 const p = normalize(await read($, progress), lessons)
52 const lesson = lessons[p.module]
53 const module = lesson?.module
54 const step = module?.steps[p.step]
55 if (!module || !step) return <Text color="red" wrap="wrap">{contentError(lesson)}</Text>
56 const done = p.ticked[checkpointKey(p)] === true
57 return (
58 <Box flexDirection="column">
59 <Text bold wrap="wrap">{`Bootcamp · Module ${p.module + 1}: ${module.title} · step ${p.step + 1}/${module.steps.length}`}</Text>
60 <Text wrap="wrap">{module.steps.map((s, i) => p.ticked[`${p.module}:${i}`] ? '[x]' : i === p.step ? '[>]' : '[ ]').join(' ')}</Text>
61 <Text bold wrap="wrap">{step.title}</Text>
62 <Text wrap="wrap">{step.body}</Text>
63 {step.try ? <Box flexDirection="column" borderStyle="round"><Text bold>Try it</Text><Text wrap="wrap">{step.try}</Text></Box> : null}
64 {step.check ? <Text wrap="wrap" color={done ? 'green' : undefined}>{done ? `[x] ${step.done ?? 'Matching tool call observed.'}` : '[ ] Waiting for you to run it'}</Text> : null}
65 {step.note ? <Text dimColor wrap="wrap">{step.note}</Text> : null}
66 <Text dimColor wrap="wrap">/bootcamp-next · /bootcamp-back</Text>
67 </Box>
68 )
69 })
70}
71hooks/bootcamp.ts 119 lines1import type { Check, Lesson, Module, Progress, Step } from '../types/index.d.ts'
2
3export function parseModule(text: string): Module {
4 const lines = text.replace(/\r\n/g, '\n').trim().split('\n')
5 const title = lines.shift()?.match(/^# (\S.*)$/)?.[1]
6 if (!title) throw new Error('Expected # Module title on the first line.')
7 const steps: Step[] = []
8 let step: Step | undefined
9 let metadata = false
10 const finish = () => {
11 if (step && !step.body.trim()) throw new Error(`Step "${step.title}" needs body text.`)
12 if (step) step.body = step.body.trim()
13 }
14 for (const line of lines) {
15 const heading = line.match(/^## (\S.*)$/)
16 if (heading?.[1]) {
17 finish()
18 step = { title: heading[1], body: '' }
19 steps.push(step)
20 metadata = false
21 continue
22 }
23 if (!line.trim()) { if (step && !metadata) step.body += '\n'; continue }
24 if (!step) throw new Error('Expected ## Step title before body text.')
25 if (/^#/.test(line)) throw new Error('Only module and step headings are allowed.')
26 const field = line.match(/^(Try|Check|Done|Note):\s*(.*)$/)
27 if (field) {
28 metadata = true
29 const value = field[2]?.trim() ?? ''
30 if (!value) throw new Error(`${field[1]} needs a value.`)
31 if (field[1] === 'Check') {
32 if (step.check) throw new Error('Duplicate Check field.')
33 const check = value.match(/^(skill|bash) (.+)$/)
34 if (!check?.[2]) throw new Error('Check must use skill <name> or bash <regex>.')
35 const kind = check[1] === 'skill' ? 'skill' : 'bash'
36 if (kind === 'bash') new RegExp(check[2], 'i')
37 else if (!/^[\w-]+(?::[\w-]+)*$/.test(check[2])) throw new Error('Invalid skill name.')
38 step.check = { kind, value: check[2] }
39 } else {
40 const key = field[1] === 'Try' ? 'try' : field[1] === 'Done' ? 'done' : 'note'
41 if (step[key]) throw new Error(`Duplicate ${field[1]} field.`)
42 step[key] = value
43 }
44 } else {
45 if (metadata) throw new Error('Body text must precede metadata.')
46 step.body += `${line}\n`
47 }
48 }
49 finish()
50 if (!steps.length) throw new Error('Module needs at least one step.')
51 return { title, steps }
52}
53
54export function loadLessons(files: ReadonlyArray<{ file: string; text: string }>): Lesson[] {
55 return files.map(({ file, text }) => {
56 try { return { file, module: parseModule(text) } }
57 catch (error) { return { file, error: error instanceof Error ? error.message : 'Invalid Markdown.' } }
58 })
59}
60
61export function matches(check: Check | undefined, event: Record<string, unknown>): boolean {
62 if (!check) return false
63 if (check.kind === 'skill') {
64 const skill = event.skill
65 return event.tool === 'Skill' && typeof skill === 'string'
66 && skill.split(':').pop() === check.value.split(':').pop()
67 }
68 if (event.tool !== 'Bash' || typeof event.command !== 'string') return false
69 if (event.command.length > 32000) return false
70 try { return new RegExp(check.value, 'i').test(event.command) } catch { return false }
71}
72
73export const initial = (): Progress => ({ module: 0, step: 0, ticked: {} })
74export const checkpointKey = (p: Progress): string => `${p.module}:${p.step}`
75
76export function normalize(value: unknown, lessons: Lesson[]): Progress {
77 if (!value || typeof value !== 'object' || Array.isArray(value)) return initial()
78 const p = value as Record<string, unknown>
79 if (typeof p.module !== 'number' || !Number.isInteger(p.module)
80 || typeof p.step !== 'number' || !Number.isInteger(p.step)
81 || !p.ticked || typeof p.ticked !== 'object' || Array.isArray(p.ticked)
82 || ![Object.prototype, null].includes(Object.getPrototypeOf(p.ticked))) return initial()
83 const module = Math.max(0, Math.min(Math.max(0, lessons.length - 1), p.module))
84 const last = (lessons[module]?.module?.steps.length ?? 1) - 1
85 const step = Math.max(0, Math.min(last, p.step))
86 const ticked = Object.fromEntries(Object.entries(p.ticked).filter(([, v]) => v === true))
87 return { module, step, ticked }
88}
89
90export function navigate(p: Progress, lessons: Lesson[], command: string, args = ''): Progress {
91 if (command === 'bootcamp-reset') return initial()
92 const current = normalize(p, lessons)
93 let { module, step } = current
94 if (command === 'bootcamp-goto') {
95 if (!/^\s*\d+\s+\d+\s*$/.test(args)) return current
96 const numbers = args.trim().split(/\s+/).map(Number)
97 module = (numbers[0] ?? 1) - 1
98 step = (numbers[1] ?? 1) - 1
99 } else if (command === 'bootcamp-next') {
100 if (step + 1 < (lessons[module]?.module?.steps.length ?? 1)) step++
101 else if (module + 1 < lessons.length) { module++; step = 0 }
102 } else if (command === 'bootcamp-back') {
103 if (step > 0) step--
104 else if (module > 0) { module--; step = (lessons[module]?.module?.steps.length ?? 1) - 1 }
105 }
106 return normalize({ ...current, module, step }, lessons)
107}
108
109export function tick(p: Progress, lessons: Lesson[], event: Record<string, unknown>): Progress {
110 const current = normalize(p, lessons)
111 const check = lessons[current.module]?.module?.steps[current.step]?.check
112 return matches(check, event)
113 ? { ...current, ticked: { ...current.ticked, [checkpointKey(current)]: true } } : current
114}
115
116export function contentError(lesson: Lesson | undefined): string {
117 return `Bootcamp content error: ${lesson?.file ?? 'unknown module'}: ${lesson?.error ?? 'No steps available.'}`
118}
119hooks/content.generated.ts 40 lines1// Generated by scripts/build-content.mjs. Do not edit.
2export const content = [
3 {
4 "file": "01-setup.md",
5 "text": "# Setup\n\n## Meet Corner Shop\n\nCorner Shop sells groceries online. Use this sample project throughout the bootcamp.\nNote: Explain that the sample contains no real customer data.\n\n## Configure ApexYard\n\nRun /setup from the ops fork. Answer the company and tool questions.\nTry: /setup\nCheck: skill setup\nDone: You configured ApexYard.\n\n## Take the guided tour\n\nRun /onboard. Learn the roles, skills, and gates before you change Corner Shop.\nTry: /onboard\nCheck: skill onboard\nDone: You started the guided tour.\n"
6 },
7 {
8 "file": "02-ideas.md",
9 "text": "# Ideas\n\n## Capture the problem\n\nCorner Shop customers need a clear delivery fee. Record the problem before you propose code.\nTry: /idea Corner Shop needs a clear delivery fee\nCheck: skill idea\nDone: You recorded the delivery fee idea.\n\n## Test the idea\n\nCheck who needs the fee display. State the value and the smallest useful change.\nTry: /validate-idea Corner Shop delivery fee\nCheck: skill validate-idea\nDone: You tested the delivery fee idea.\n\n## Write the spec\n\nA spec states the required behavior. Define acceptance criteria that a reviewer can test.\nTry: /write-spec Corner Shop delivery fee\nCheck: skill write-spec\nDone: You wrote the delivery fee spec.\nNote: Explain that an approved spec precedes build work.\n"
10 },
11 {
12 "file": "03-existing-repo.md",
13 "text": "# Bring an existing repo\n\n## Choose the repo\n\nUse the existing Corner Shop repo. Check that you can read its source and tests.\n\n## Run the handover\n\nRun /handover for Corner Shop. Let ApexYard inspect the repo and identify missing project documents.\nTry: /handover corner-shop\nCheck: skill handover\nDone: You ran the repo handover.\n\n## Review the findings\n\nRead the findings. Select the documents you need. Confirm the next steps before you start a change.\nNote: Use the real findings from the sample repo.\n"
14 },
15 {
16 "file": "04-tickets.md",
17 "text": "# Tickets\n\n## Request a feature\n\nA feature ticket describes new behavior. State the user need and the acceptance criteria.\nTry: /feature Corner Shop delivery fee\nCheck: skill feature\nDone: You filed the feature ticket.\n\n## Report a bug\n\nA bug ticket describes incorrect behavior. Give the expected result and the steps that reproduce the fault.\nTry: /bug Corner Shop basket total is incorrect\nCheck: skill bug\nDone: You filed the bug ticket.\n\n## Describe a task\n\nA task ticket describes technical work. State the reason, scope, and checks.\nTry: /task Corner Shop improve basket tests\nCheck: skill task\nDone: You filed the task ticket.\n\n## Start one ticket\n\nRun /start-ticket with a real ticket number. The active ticket connects code edits to the approved work.\nTry: /start-ticket <ticket-number>\nCheck: skill start-ticket\nDone: You started the ticket.\nNote: Replace placeholders with real ticket numbers. Keep one ticket active.\n"
18 },
19 {
20 "file": "05-vibe-coded-request.md",
21 "text": "# A vibe-coded request\n\n## Type a plain request\n\nAsk for a Corner Shop login page. Plain words describe the goal. They do not bypass the gates.\nTry: build me a login page\nNote: Demonstrate this request with no active ticket.\n\n## Observe the blocked edit\n\nThe ticket gate blocks code edits when no ticket is active. Read the gate message before you continue.\nNote: Show the actual hook message. The pane does not enforce this gate.\n\n## File a ticket\n\nCreate a feature ticket for the login page. Define the behavior and the acceptance criteria.\nTry: /feature Corner Shop login page\nCheck: skill feature\nDone: You filed the login page ticket.\n\n## Start it and name the branch\n\nStart the real ticket. Use a branch such as feature/GH-<ticket-number>-login-page. Keep this change within that ticket.\nTry: /start-ticket <ticket-number>\nCheck: skill start-ticket\nDone: You started the ticket.\n\n## Open a PR\n\nA pull request, or PR, presents the change for review. Include the ticket number and test results.\nTry: open a PR\nCheck: bash (?:^|&&|\\|\\||[;\\n])\\s*gh\\s+pr\\s+create\\b(?![^;\\n&|]*--help\\b)\nDone: You opened a pull request.\n\n## Ask Rex to review\n\nRex is the code reviewer agent. Run /code-review for the PR. Resolve his findings before you request approval.\nTry: /code-review <pr-number>\nCheck: skill code-review\nDone: You asked Rex for a review.\nNote: A matching skill call does not prove that Rex approved the PR.\n"
22 },
23 {
24 "file": "06-diagrams.md",
25 "text": "# Diagrams\n\n## Show the system\n\nUse /c4 to show Corner Shop users, external systems, and containers. A container is a deployable application unit.\nTry: /c4 corner-shop\nCheck: skill c4\nDone: You drew the C4 diagrams.\n\n## Show the process\n\nUse /process to show how Corner Shop handles an order. Compare the diagram with the actual business steps.\nTry: /process corner-shop\nCheck: skill process\nDone: You drew the order process diagram.\n\n## Show the sequence\n\nAsk for a sequence diagram in plain words. Show the order of calls during Corner Shop checkout.\nTry: draw a sequence diagram for Corner Shop checkout\nNote: No dedicated sequence skill exists. Review the generated diagram with the class.\n\n## Show the data flow\n\nUse /dfd to show data flows and trust boundaries. A trust boundary separates systems with different access controls.\nTry: /dfd corner-shop\nCheck: skill dfd\nDone: You drew the data flow diagram.\n"
26 },
27 {
28 "file": "07-security.md",
29 "text": "# Security\n\n## Identify threats\n\nUse /threat-model for Corner Shop. Examine the data flows and trust boundaries. Record threats and controls.\nTry: /threat-model corner-shop\nCheck: skill threat-model\nDone: You ran the threat model.\n\n## Review the change\n\nUse /security-review for the real login PR. Hakim checks security risks in the change.\nTry: /security-review <pr-number>\nCheck: skill security-review\nDone: You asked Hakim for a security review.\n\n## Resolve the findings\n\nFix confirmed security faults within the active ticket scope. Repeat the checks before you request approval.\nNote: Explain each finding with evidence. Do not assume that a skill call means approval.\n"
30 },
31 {
32 "file": "08-launch.md",
33 "text": "# Launch\n\n## Check readiness\n\nUse /launch-check before you launch Corner Shop. Read each result and resolve blocking findings.\nTry: /launch-check corner-shop\nCheck: skill launch-check\nDone: You ran the launch readiness check.\n\n## Check accessibility\n\nUse /accessibility-audit to check how customers use Corner Shop. Resolve barriers that prevent access.\nTry: /accessibility-audit corner-shop\nCheck: skill accessibility-audit\nDone: You ran the accessibility audit.\n\n## Check performance\n\nUse /performance-audit to inspect Corner Shop load time and runtime behavior. Record measured results.\nTry: /performance-audit corner-shop\nCheck: skill performance-audit\nDone: You ran the performance audit.\n\n## Check monitoring\n\nUse /monitoring-audit to check alerts and health checks. Confirm who responds when Corner Shop fails.\nTry: /monitoring-audit corner-shop\nCheck: skill monitoring-audit\nDone: You ran the monitoring audit.\n\n## Review the launch evidence\n\nCheck the audit results with the team. Use /compliance-check and /docs-audit when the launch needs those checks.\nNote: Choose additional audit skills from the actual project needs.\n"
34 },
35 {
36 "file": "09-review-and-merge.md",
37 "text": "# Review and merge\n\n## Request code review\n\nRun /code-review for the Corner Shop PR. Rex checks the change against the ticket and tests.\nTry: /code-review <pr-number>\nCheck: skill code-review\nDone: You asked Rex for a review.\n\n## Read the verdict\n\nResolve blocking findings. Confirm that checks pass and Rex approved the current PR revision.\nNote: A checkpoint confirms a call. It does not confirm the review verdict.\n\n## Approve this PR\n\nGive explicit approval for this PR. Run /approve-merge with its real number. The merge gate checks the required evidence.\nTry: /approve-merge <pr-number>\nCheck: skill approve-merge\nDone: You requested merge approval for this pull request.\nNote: Only the human can authorize this merge. Do not run this command during an unapproved demo.\n\n## Verify the result\n\nCheck the merged change against every acceptance criterion. Confirm QA results before you mark the ticket done.\nNote: QA means quality assurance. Use the real results from Corner Shop.\n"
38 }
39] as const
40types/index.d.ts 12 lines1export type Check = { kind: 'skill' | 'bash'; value: string }
2export type Step = { title: string; body: string; try?: string; check?: Check; done?: string; note?: string }
3export type Module = { title: string; steps: Step[] }
4export type Lesson = { file: string; module?: Module; error?: string }
5export type Progress = { module: number; step: number; ticked: Record<string, boolean> }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'apexyard-bootcamp': { progress: Progress }
10 }
11}
12