Barre de couverture des tests lue dans docs/test-log.md (validé, à revalider, problème, non testé)

Generic toolkit behind MyCareer Workspace: a personal, persistent and local career workspace, built around a living professional profile that you own.
French-first pilot: the most complete getting-started guide is README.fr.md.
MyCareer Workspace (in French, « espace carrière ») is a private workspace of files that you open in VS Code, Claude Code or another file-aware AI tool. Its core is a living professional profile: structured professional memory, validated by you, the starting point of your work on opportunities and interviews, and enriched by that work over time. Each opportunity, preparation, simulation and debriefing can consolidate it, so the work is not consumed by a one-off use: it becomes a reusable professional asset.
AI (analysis, synthesis, coaching) is a means, not the value proposition. The structuring principles are:
career-ai-toolkit remains the name of this repository: the generic project that gathers the components, skills, templates, tools and deliverables of the product.
The toolkit is a coach, not an answer generator: it helps candidates reflect, practice, improve and make their professional story their own, rather than memorize ready-made answers. The only exception is the joker, on explicit request during an interview simulation: a proposed answer is clearly labelled as such.
MyCareer Workspace: build and extract the private workspace ZIP, then open it in VS Code, Claude Code, or another file-aware AI tool. See the user guide.
The local directory name is free: a workspace extracted under career-ai-workspace/ keeps working and does not need to be renamed. At the next session the user section is added to its workspace.yaml and the first-name question is asked, without the welcome message.
This repository contains only generic instructions, templates, and a fully fictional example. Never add real resumes, certifications, job descriptions, interview notes, or other personal data to this repository. Keep personal data in a separate private workspace, preferably on a personal device or a storage location you control. See docs/privacy.md for the privacy of exchanges with the AI tool and the web browsing rule.
On Windows, run:
build.bat
The command creates versioned release artifacts under the untracked dist/ directory: the MyCareer Workspace ZIP, the demo and manual-test kit ZIP and the pilot feedback form.
The toolkit is in a pilot phase. The current priority is validating the redesigned coaching workflow with real users before adding a CV generator, installers, automatic updates, or a full document-rendering pipeline.
Created and maintained by Jérôme Jurbert.
This project grew out of a practical need to structure career information, job applications, and interview preparation using AI-assisted workflows.
Career AI Toolkit is released under the MIT License.
hooks/register.tsx 149 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Coverage } from '../types'
5import { KINDS, LABEL, SQUARE, allocate, parse, parseSections, sectionTable, total } from './coverage'
6import type { Kind } from './coverage'
7
8const LOG = 'docs/test-log.md'
9const PANE = 'test-coverage'
10const COMMAND = 'test-coverage'
11const POLL_MS = 3000
12const MAX_BAR = 60
13
14const coverage = atom({ plugin: 'test-coverage', key: 'coverage' } as const, null)
15
16const COLOR: Readonly<Record<Kind, string>> = {
17 validated: 'green',
18 revalidate: '#e08a00',
19 problem: '#d83d65',
20 untested: 'gray',
21}
22
23type Elements = ReturnType<EngineInterface['ui']['resolve']>
24
25function bar(ui: Elements, counts: Coverage, columns: number) {
26 const { Box, Text } = ui
27 const width = Math.max(10, Math.min(MAX_BAR, columns - 16))
28 const cells = allocate(counts, width, true)
29 const percents = allocate(counts, 100)
30
31 return (
32 <Box flexDirection="column">
33 <Box>
34 <Text bold>Tests </Text>
35 {KINDS.filter(kind => cells[kind] > 0).map(kind => (
36 <Text key={`bar-${kind}`} color={COLOR[kind]}>
37 {'█'.repeat(cells[kind])}
38 </Text>
39 ))}
40 <Text dimColor> – {total(counts)} au total</Text>
41 </Box>
42 <Box>
43 {KINDS.map((kind, index) => (
44 <Text key={`legend-${kind}`} color={COLOR[kind]}>
45 {index === 0 ? '' : ' '}■ {LABEL[kind]} {counts[kind]} ({percents[kind]} %)
46 </Text>
47 ))}
48 </Box>
49 </Box>
50 )
51}
52
53const STATUS_CELLS = 20
54
55// Plain-text bar returned by the command, the one view every surface shows: the bar on
56// its own line (below the engine's "test-coverage:" prefix), then the counts per status.
57function textBar(counts: Coverage): string {
58 const cells = allocate(counts, STATUS_CELLS, true)
59 const percents = allocate(counts, 100)
60 const squares = KINDS.map(kind => SQUARE[kind].repeat(cells[kind])).join('')
61 const legend = KINDS.map(kind => `${SQUARE[kind]} ${LABEL[kind]} ${counts[kind]} (${percents[kind]} %)`).join(' ')
62
63 return `
64Tests ${squares} – ${total(counts)} au total
65${legend}`
66}
67
68let lastMtime = -1
69
70// Re-reads the log only when its modification time changed.
71async function refresh($: EngineInterface): Promise<void> {
72 try {
73 const path = `${await $.session.cwd()}/${LOG}`
74 if (!(await $.fs.exists(path))) {
75 lastMtime = -1
76 await update($, coverage, () => null)
77 return
78 }
79 const { mtimeMs } = await $.fs.stat(path)
80 if (mtimeMs === lastMtime) return
81 const counts = parse(await $.fs.read(path))
82 lastMtime = mtimeMs
83 await update($, coverage, () => (total(counts) === 0 ? null : counts))
84 } catch {
85 // A log being rewritten can fail to read; the next tick tries again.
86 lastMtime = -1
87 }
88}
89
90export const register: Register = on => {
91 lastMtime = -1
92
93 on('session.start', async ($, e, next) => {
94 await $.command.register({
95 name: COMMAND,
96 description: 'Affiche la couverture des tests (-v : détail par section)',
97 argumentHint: '[-v]',
98 })
99 await refresh($)
100 $.clock.every(POLL_MS, () => void refresh($))
101
102 return next(e)
103 })
104
105 on('turn.complete', async ($, e, next) => {
106 await refresh($)
107
108 return next(e)
109 })
110
111 on('command.run', { command: COMMAND }, async ($, e) => {
112 const args = e.args.trim().split(/\s+/).filter(arg => arg !== '')
113 const isVerbose = args.some(arg => arg === '-v' || arg === '--verbose')
114 if (args.some(arg => arg !== '-v' && arg !== '--verbose')) {
115 return { text: `Usage : /${COMMAND} [-v | --verbose]` }
116 }
117
118 lastMtime = -1
119 await refresh($)
120 const counts = await read($, coverage)
121 if (counts === null) {
122 return { text: `Aucun statut de test trouvé dans ${LOG}.` }
123 }
124 // VS Code reports the pane as placed without drawing it: the command's own text is the reliable view.
125 await $.ui.open({ id: PANE, title: 'Couverture des tests' })
126
127 const summary = textBar(counts)
128 if (!isVerbose) return { text: summary }
129 const sections = parseSections(await $.fs.read(`${await $.session.cwd()}/${LOG}`))
130
131 return { text: `${summary}\n\n${sectionTable(sections)}` }
132 })
133
134 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
135 const counts = await read($, coverage)
136 if (e.props.hasSurvey || counts === null) return next(e)
137
138 return bar($.ui.resolve(e), counts, e.props.bodyColumns)
139 })
140
141 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
142 const ui = $.ui.resolve(e)
143 const counts = await read($, coverage)
144 if (counts === null) return <ui.Text dimColor>Aucun statut de test trouvé dans {LOG}.</ui.Text>
145
146 return bar(ui, counts, e.props.bodyColumns)
147 })
148}
149hooks/coverage.ts 158 lines1import type { Coverage } from '../types'
2
3export type Kind = keyof Coverage
4
5export const KINDS: readonly Kind[] = ['validated', 'revalidate', 'problem', 'untested']
6
7const LABELS: Readonly<Record<string, Kind>> = {
8 'Validé': 'validated',
9 'À revalider': 'revalidate',
10 'Problème constaté': 'problem',
11 'Non testé': 'untested',
12}
13
14// A status cell, with or without its emoji: "✅ Validé", "⚠️ À revalider", "Non testé".
15const STATUS = /^(?:\S+\s+)?(Validé|À revalider|Problème constaté|Non testé)$/u
16
17export function empty(): Coverage {
18 return { validated: 0, revalidate: 0, problem: 0, untested: 0 }
19}
20
21export type Section = { title: string; coverage: Coverage }
22
23// The status of a table row: its first cell that is a status alone.
24function rowStatus(line: string): Kind | undefined {
25 if (!line.startsWith('|')) return undefined
26 for (const cell of line.split('|')) {
27 const label = STATUS.exec(cell.trim().normalize('NFC'))?.[1]
28 if (label !== undefined) return LABELS[label]
29 }
30
31 return undefined
32}
33
34// Counts the status cell of every table row under the heading it falls in;
35// prose lines such as the legend are ignored, sections without a status are left out.
36export function parseSections(text: string): Section[] {
37 const sections: Section[] = []
38 let title = ''
39 let current: Section | undefined
40 for (const raw of text.split(/\r?\n/)) {
41 const line = raw.trim()
42 const heading = /^#{1,6}\s+(.*)$/.exec(line)
43 if (heading?.[1] !== undefined) {
44 title = heading[1].trim()
45 current = undefined
46 continue
47 }
48 const kind = rowStatus(line)
49 if (kind === undefined) continue
50 if (current === undefined) {
51 current = { title, coverage: empty() }
52 sections.push(current)
53 }
54 current.coverage[kind] += 1
55 }
56
57 return sections
58}
59
60export function parse(text: string): Coverage {
61 const coverage = empty()
62 for (const section of parseSections(text)) {
63 for (const kind of KINDS) coverage[kind] += section.coverage[kind]
64 }
65
66 return coverage
67}
68
69// "Scénarios à satisfaire pour l'issue #12 (kit de test…)" reads "Scénarios #12".
70export function shortTitle(title: string): string {
71 const issue = /issue (#\d+)/.exec(title)?.[1]
72 if (issue !== undefined) return `Scénarios ${issue}`
73 const label = title.replace(/\s*\(.*\)\s*$/, '')
74
75 return label.length > 40 ? `${label.slice(0, 39)}…` : label
76}
77
78export const SQUARE: Readonly<Record<Kind, string>> = {
79 validated: '🟩',
80 revalidate: '🟨',
81 problem: '🟥',
82 untested: '⬜',
83}
84
85export const LABEL: Readonly<Record<Kind, string>> = {
86 validated: 'Validé',
87 revalidate: 'À revalider',
88 problem: 'Problème',
89 untested: 'Non testé',
90}
91
92// U+2007, as wide as a digit in proportional fonts too.
93const FIGURE_SPACE = ' '
94
95// One line per section and a total line. Every line holds the same glyphs at the same
96// places (each number right-aligned after its square with figure spaces) and ends with the title,
97// so the columns line up in a proportional font (VS Code) as in a terminal.
98export function sectionTable(sections: readonly Section[]): string {
99 const all = empty()
100 for (const section of sections) {
101 for (const kind of KINDS) all[kind] += section.coverage[kind]
102 }
103 const digits = String(total(all)).length
104 // Figure spaces also keep the gaps where a webview collapses runs of plain spaces.
105 const gap = FIGURE_SPACE.repeat(4)
106 const line = (title: string, coverage: Coverage) =>
107 [
108 String(total(coverage)).padStart(digits, FIGURE_SPACE),
109 ...KINDS.map(kind => `${SQUARE[kind]}${String(coverage[kind]).padStart(digits, FIGURE_SPACE)}`),
110 title,
111 ].join(gap)
112 const legend = `Lignes · ${KINDS.map(kind => `${SQUARE[kind]} ${LABEL[kind]}`).join(' · ')} · Section`
113
114 return [
115 legend,
116 '',
117 ...sections.map(section => line(shortTitle(section.title), section.coverage)),
118 '',
119 line('Total', all),
120 ].join('\n')
121}
122
123export function total(coverage: Coverage): number {
124 return KINDS.reduce((sum, kind) => sum + coverage[kind], 0)
125}
126
127// Splits `size` units across the kinds by largest remainder, so the parts add up to `size`.
128// With `isVisible`, every kind that has zones gets at least one unit, taken from the largest.
129export function allocate(coverage: Coverage, size: number, isVisible = false): Coverage {
130 const sum = total(coverage)
131 const parts = empty()
132 if (sum === 0 || size <= 0) return parts
133
134 const exact = KINDS.map(kind => ({ kind, value: (coverage[kind] * size) / sum }))
135 for (const { kind, value } of exact) parts[kind] = Math.floor(value)
136 let left = size - total(parts)
137 const byRemainder = [...exact].sort(
138 (a, b) => b.value - Math.floor(b.value) - (a.value - Math.floor(a.value)),
139 )
140 for (const { kind } of byRemainder) {
141 if (left === 0) break
142 parts[kind] += 1
143 left -= 1
144 }
145
146 if (isVisible) {
147 for (const kind of KINDS) {
148 if (coverage[kind] === 0 || parts[kind] > 0) continue
149 const donor = [...KINDS].sort((a, b) => parts[b] - parts[a])[0]
150 if (donor === undefined || parts[donor] <= 1) continue
151 parts[donor] -= 1
152 parts[kind] += 1
153 }
154 }
155
156 return parts
157}
158types/index.d.ts 13 lines1export type Coverage = {
2 validated: number
3 revalidate: number
4 problem: number
5 untested: number
6}
7
8declare module 'claude-code' {
9 interface PluginState {
10 'test-coverage': { coverage: Coverage | null }
11 }
12}
13