SLOPSHOPPER

test-coverage

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

newpanebandcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · test-coverage
│ ┃ test-coverage ✕ › fix the failing auth test and add an audit log call │ ┃ Aucun statut de test trouvé dans │ ┃ docs/test-log.md. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ 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 │ │ › /test-coverage │ ⎿ test-coverage: Aucun statut de test trouvé dans docs/test-log.md │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · test-coverage
Aucun statut de test trouvé dans docs/test-log.md.
README

Career AI Toolkit

License: MIT

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

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:

  • you own and control your data;
  • confidentiality;
  • persistence and continuity between conversations;
  • portability (plain files);
  • transparency and traceability;
  • your validation of any information that is capitalized.

career-ai-toolkit remains the name of this repository: the generic project that gathers the components, skills, templates, tools and deliverables of the product.

Coaching principle

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.

How to use it

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.

Privacy model

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.

Build

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.

Project status

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.

Documentation

Author

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.

Source 3 files
hooks/register.tsx 149 lines
1import { 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}
149
hooks/coverage.ts 158 lines
1import 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}
158
types/index.d.ts 13 lines
1export 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