Negotiate Gherkin acceptance criteria before any code, open the ticket in your tracker (YouTrack or Azure DevOps) and name the branch after it, then collect…

Acceptance criteria first, proofs last, and the ticket in between: Claude negotiates the rules with you in Gherkin before writing code, files the ticket in your tracker, names the branch after it, and collects every proof into that ticket.
Rule:), asks the questions that change them, and waits for your go-ahead. The rules you approve go into the ticket description.feature/<ID> (fix/<ID> for a bug). Creating a branch without a ticket number is refused..txt files; every file is attached to the ticket with a comment.<ID> — Ticket, <ID> — Règles (a checklist by subject) and <ID> — Preuves (a gallery with a full-screen viewer) are published as private claude.ai artifacts and kept up to date.Ticket <ID> on the left, Règles x/y and Preuves n on the right, each opening its artifact (cmd+click or middle-click opens it in the browser).The criteria, prompts and artifacts are written in French.
Two things are set up separately:
Write the token as <tracker url> <token>, for example https://acme.youtrack.cloud perm:abc…. This binds it to that tracker, and nothing else needs approving (see Why the URL goes with the token).
In a Claude Code session opened on the repository, type:
/acceptance-setup https://<org>.youtrack.cloud CORE,GROWTH
or, for Azure DevOps:
/acceptance-setup https://dev.azure.com/<organization> "<project>" [work item type]
| Argument | YouTrack | Azure DevOps |
|---|---|---|
| URL | https://<org>.youtrack.cloud | https://dev.azure.com/<organization> (detected from the host) |
| Then | Short names tickets may be created in, the default first: CORE,GROWTH | The project that holds the work items, then the work item type (User Story by default) |
| Ticket IDs | CORE-42 → branch feature/CORE-42 | 1234 → branch feature/1234, shown as #1234 |
The command writes .claude/acceptance.json at the root of the git repository. Commit and push it: every session, local or cloud, reads the tracker from it. This command is the only way to set the tracker.
Works the same in every local surface: the CLI, Claude Desktop and the IDE extensions.
claude plugin marketplace add badjilounes/claude-code-plugins
claude plugin install acceptance@badjilounes
Already installed? claude plugin marketplace update badjilounes && claude plugin update acceptance@badjilounes.
~/.config/acceptance/token, readable by you only. The file holds one line per tracker, <tracker url> <token>; each repository gets the line whose URL matches its .claude/acceptance.json. Paste the token alone when the command waits; it adds the line with the URL in front: # YouTrack
mkdir -p ~/.config/acceptance && read -rs T && printf 'https://<org>.youtrack.cloud %s\n' "$T" >> ~/.config/acceptance/token && chmod 600 ~/.config/acceptance/token; unset T
# Azure DevOps (a personal access token with Work Items read & write)
mkdir -p ~/.config/acceptance && read -rs T && printf 'https://dev.azure.com/<organization> %s\n' "$T" >> ~/.config/acceptance/token && chmod 600 ~/.config/acceptance/token; unset T
Run one command per tracker you use (>> appends). The result looks like this; lines starting with # are ignored:
https://acme.youtrack.cloud perm:…
https://dev.azure.com/acme 3x…
To replace a token, edit its line. The URL must be exactly the one /acceptance-setup wrote (for Azure DevOps, the organization URL, without the project).
The plugin reads this file itself, so it does not depend on how Claude Code was started. An app opened from the Dock or a launcher does not get the variables of your shell profile; this file does not need them.
/exit then claude in the CLI, or quit the app completely (Cmd+Q / Alt+F4) and reopen it.use_ticket CORE-42). It switches to feature/CORE-42.Each place takes the same <tracker url> <token> form, several pairs separated by new lines or ;. For a repository, the plugin uses the first pair whose URL matches its tracker, looking in this order:
token option, kept in secure storage, set with claude plugin configure acceptance@badjilounes --values-stdin (JSON {"token":"<url> <token>"} on stdin) or /config. An option saved by one surface may not be seen by another (another copy of the plugin, for instance one synced from claude.ai), so prefer the file when you use several.ACCEPTANCE_TRACKER_TOKEN environment variable. In a cloud environment this is the way, see 2b.~/.config/acceptance/token (%USERPROFILE%\.config\acceptance\token on Windows).A token without a URL in front, left over from an earlier setup, is only a fallback, but it is sent to whichever tracker /acceptance-setup approved: clear it with printf '{"token":""}' | claude plugin configure acceptance@badjilounes --values-stdin.
.claude/settings.json: {
"extraKnownMarketplaces": { "badjilounes": { "source": { "source": "github", "repo": "badjilounes/claude-code-plugins" } } },
"enabledPlugins": { "acceptance@badjilounes": true }
}
ACCEPTANCE_TRACKER_TOKEN=https://<org>.youtrack.cloud perm:…
Several trackers in the same environment: separate the pairs with ;, for example https://<org>.youtrack.cloud perm:…; https://dev.azure.com/<organization> 3x….
use_ticket CORE-42).The tools say exactly what is missing instead of calling the ticket missing:
| Message | Fix |
|---|---|
n'est pas configuré : URL (/acceptance-setup) | No .claude/acceptance.json at the repository root: step 1, then commit it. |
n'est pas configuré : jeton | No token where this session runs: step 2a or 2b. Locally, check ~/.config/acceptance/token exists and restart the app; works in the CLI but not in Desktop usually means the token sat in the shell profile or in another surface's option, so move it to the file. In the cloud, start a new session after adding the variable. |
n'a pas été approuvé sur cette machine | Either there is no token at all (fix the token first, the message above then shows), or the token has no URL in front, or a different one: write it as <tracker url> <token>, with the exact URL of .claude/acceptance.json. Or type /acceptance-setup <url> <projects> yourself, which approves the tracker on this machine only. |
inaccessible sur <url> : … 401 / 404 | The tracker refused the token, or the ticket does not exist for that account: check the token's rights on the project. |
Without a configured tracker the plugin still negotiates rules and keeps proofs locally; they reach the ticket once it is configured.
.claude/acceptance.json comes with the repository, so on its own it could point anywhere. The token is therefore only sent to a tracker you approved:
<tracker url> <token>, in the file, the option or the variable) approves exactly that URL. A repository can override the variable, but only with a token of its own, so it never gets yours; it cannot write your home folder or your plugin options./acceptance-setup typed by you approves the tracker (kind, URL, project) in the plugin's user store on this machine. Only a person typing the command (at the prompt or through Remote Control) approves. A cloud container starts with an empty store, which is why the cloud uses a bound token.Only https URLs without credentials, query or fragment are accepted, and proof files are only downloaded from the tracker's URL. When several places hold a token for the same tracker, the option wins, then the variable, then the file. A token without a URL in front is only used when no place holds one bound to the repository's tracker.
Several people can work on the same repository, each with their own token. Nothing secret is shared.
Shared, in the repository:
.claude/acceptance.json: the tracker kind, URL and project. No secret..claude/settings.json: the marketplace and "enabledPlugins": { "acceptance@badjilounes": true }, so every session on the repository loads the plugin.Each collaborator, once, on their own machine:
~/.config/acceptance/token, on the line of the repository's tracker (2a, step 2)./acceptance-setup is needed: the tracker is already in the repository, and the token is bound to its URL.Each collaborator, in the cloud: their own cloud environment, with their own ACCEPTANCE_TRACKER_TOKEN (2b). The variable belongs to the environment: people who share one environment share the token in it.
What this gives you:
| Traceability | Everyone acts under their own identity: tickets created, rules saved and proofs attached appear in the tracker under the name of whoever did the work. |
| Revocation | Revoking one collaborator's token affects nobody else. |
| Safety | A token never leaves its owner's machine and is only sent to the URL on its own line: a .claude/acceptance.json edited to point elsewhere gets nothing. |
Tracker rights: each collaborator needs to create and edit tickets in the project. In Azure DevOps, that is usually the project's Contributors group with a Basic access level. In YouTrack, a role that can create and update issues in the project.
| Tool | What it does |
|---|---|
mcp__acceptance__save_acceptance_criteria | Saves the approved Gherkin locally and into the ticket description. |
mcp__acceptance__create_ticket | Creates the ticket with its context and rules, then switches to feature/<ID> or fix/<ID>. |
mcp__acceptance__use_ticket | Resumes an existing ticket on its branch. |
mcp__acceptance__add_evidence | Adds a proof (capture, video, cURL, test, artifact, file) tied to a scenario. |
/acceptance-setup configures the tracker; /criteres prints the ticket and the rules of the current branch.
Everything is kept under .claude/acceptance/ in the repository: <branch>.feature, <branch>.evidence.json, the proof files and the published pages. Add the folder to your .gitignore (but keep .claude/acceptance.json).
claude plugin validate plugins/acceptance
claude plugin test plugins/acceptance
hooks/tracker.ts is the only file that knows a tracker: one pure adapter that builds the requests for YouTrack and Azure DevOps. Another tracker is a new kind there.
hooks/register.tsx 1214 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { BranchInfo, BranchStatus, Evidence, EvidenceKind, EvidenceMedia, Pages, Ticket } from '../types'
4
5import { proofsPage, rulesPage, ticketPage } from './pages'
6import {
7 authHeader,
8 commentRequest,
9 commentsRequest,
10 configOf,
11 createRequest,
12 createdIdOf,
13 descriptionFor,
14 descriptionRequest,
15 displayId,
16 exampleId,
17 isReady,
18 isTicketId,
19 curlLines,
20 isProofId,
21 isTrackerUrl,
22 labelOf,
23 safeFileName,
24 SETTINGS_FILE,
25 settingsOf,
26 normalizedUrl,
27 trackerName,
28 issueOf,
29 issueRequest,
30 issueUrl,
31 linkAttachmentRequest,
32 projectsRequest,
33 proofMarker,
34 proofOf,
35 ticketOf,
36 uploadLines,
37 uploadedUrlOf,
38 withRules,
39} from './tracker'
40import type { HttpRequest, Issue, TrackerConfig } from './tracker'
41import type { PageLinks, PageProof, PageSubject, PageTicket } from './pages'
42
43import { THEME, ring } from './look'
44
45const RULES = 'acceptance-rules'
46const PROOFS = 'acceptance-proofs'
47const TOOL = 'add_evidence'
48const SETUP = 'acceptance-setup'
49const TOKEN_FILE = '.config/acceptance/token'
50const KINDS: readonly EvidenceKind[] = ['capture', 'video', 'curl', 'test', 'artifact', 'file', 'other']
51const IMAGE = /\.(png|jpe?g|gif|webp|heic)$/i
52const VIDEO = /\.(mp4|mov|webm|m4v)$/i
53const KEPT = /\.(png|jpe?g|gif|webp|heic|mp4|mov|webm|m4v|pdf|txt|log|json|har|md)$/i
54const TEXT = /\.(txt|log|json|har|md)$/i
55const TEST_RUN = /(?:^|[;&|(]\s*|&&\s*)(?:npx\s+)?(?:jest|vitest|playwright test|maestro test|nx (?:run-many\s+[^\n]*?)?test|npm (?:run )?(?:test|verify))\b/m
56
57const EVIDENCE = { plugin: 'acceptance', key: 'evidence' } as const
58const BRANCH = { plugin: 'acceptance', key: 'branch' } as const
59const CRITERIA = { plugin: 'acceptance', key: 'criteria' } as const
60const TICKET = { plugin: 'acceptance', key: 'ticket' } as const
61const COLLAPSED = { plugin: 'acceptance', key: 'collapsed' } as const
62const PAGES = { plugin: 'acceptance', key: 'pages' } as const
63
64const NO_BRANCH: BranchInfo = { name: '', status: 'none', url: '', pr: 0 }
65const NO_TICKET: Ticket = { id: '', url: '', summary: '', context: '' }
66const NO_PAGES: Pages = { ticket: '', rules: '', proofs: '' }
67
68const thumbnails = new Map<string, string>()
69
70const GROUP_OF: Record<EvidenceKind, string> = {
71 capture: 'Captures',
72 video: 'Vidéos',
73 curl: 'Requêtes cURL',
74 test: 'Tests',
75 artifact: 'Artefacts',
76 file: 'Fichiers',
77 other: 'Autres preuves',
78}
79const GLYPH: Record<EvidenceMedia, string> = { image: '▣', video: '▶', text: '≡', pdf: '⎙', '': '↗' }
80
81function linkOf(item: Evidence): string {
82 return item.detail.match(/https:\/\/\S+/)?.[0] ?? ''
83}
84
85type Scenario = { name: string; steps: string[] }
86type Subject = { name: string; scenarios: Scenario[] }
87type Rules = { feature: string; subjects: Subject[] }
88
89function parseRules(text: string): Rules {
90 const rules: Rules = { feature: '', subjects: [] }
91 for (const raw of text.split('\n')) {
92 const line = raw.trim()
93 const feature = line.match(/^(?:Feature|Fonctionnalité)\s*:\s*(.*)$/)?.[1]
94 const rule = line.match(/^(?:Rule|Règle)\s*:\s*(.*)$/)?.[1]
95 const scenario = line.match(/^(?:Scenario|Scénario)(?: Outline)?\s*:\s*(.*)$/)?.[1]
96 if (feature !== undefined) rules.feature = feature
97 else if (rule !== undefined) rules.subjects.push({ name: rule, scenarios: [] })
98 else if (scenario !== undefined) {
99 if (rules.subjects.length === 0) rules.subjects.push({ name: '', scenarios: [] })
100 rules.subjects[rules.subjects.length - 1]?.scenarios.push({ name: scenario, steps: [] })
101 } else if (/^(Given|When|Then|And|But|\*)\b/.test(line)) {
102 const subject = rules.subjects[rules.subjects.length - 1]
103 subject?.scenarios[subject.scenarios.length - 1]?.steps.push(line)
104 }
105 }
106
107 return rules
108}
109
110function sameName(a: string, b: string): boolean {
111 return a.trim().toLowerCase() === b.trim().toLowerCase()
112}
113
114function grouped(list: Evidence[]): [string, Evidence[]][] {
115 const groups = new Map<string, Evidence[]>()
116 for (const item of list) {
117 const heading = item.title || GROUP_OF[item.kind]
118 groups.set(heading, [...(groups.get(heading) ?? []), item])
119 }
120 const visual = (items: Evidence[]) => (items.some(one => one.media !== '') ? 0 : 1)
121
122 return [...groups.entries()].sort((a, b) => visual(a[1]) - visual(b[1]))
123}
124
125function slug(name: string): string {
126 return (name || 'detached').replace(/[^A-Za-z0-9._-]/g, '_')
127}
128
129function cropped(item: Evidence, base64: string, width: number, height: number): string {
130 const r = Math.min(10, Math.round(width / 12))
131 const play =
132 item.media === 'video'
133 ? `<circle cx="${width / 2}" cy="${height / 2}" r="${Math.max(10, Math.round(width / 9))}" fill="rgba(0,0,0,0.55)"/><path d="M${width / 2 - width / 30} ${height / 2 - width / 20} L${width / 2 + width / 18} ${height / 2} L${width / 2 - width / 30} ${height / 2 + width / 20} Z" fill="#fff"/>`
134 : ''
135
136 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><defs><clipPath id="c"><rect width="${width}" height="${height}" rx="${r}"/></clipPath></defs><g clip-path="url(#c)"><rect width="${width}" height="${height}" fill="#E8E5DC"/><image href="data:image/jpeg;base64,${base64}" width="${width}" height="${height}" preserveAspectRatio="xMidYMin slice"/>${play}</g><rect x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" rx="${r}" fill="none" stroke="rgba(138,137,132,0.35)"/></svg>`
137}
138
139function mediaOf(path: string): EvidenceMedia {
140 if (IMAGE.test(path)) return 'image'
141 if (VIDEO.test(path)) return 'video'
142 if (TEXT.test(path)) return 'text'
143 return /\.pdf$/i.test(path) ? 'pdf' : ''
144}
145
146function fileSlug(text: string): string {
147 return text.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 48) || 'preuve'
148}
149
150function textProof(proof: { kind: EvidenceKind; title: string; subtitle: string; scenario: string; detail: string }): string {
151 const heading = [proof.title || GROUP_OF[proof.kind], proof.subtitle].filter(Boolean).join(' — ')
152 const scenario = proof.scenario !== '' ? `# Scénario : ${proof.scenario}\n` : ''
153
154 return `# ${heading}\n${scenario}# Type : ${proof.kind}\n\n${proof.detail.trim()}\n`
155}
156
157function repoUrl(remote: string): string {
158 const match = remote.trim().match(/github\.com[:/](.+?)(\.git)?$/)
159
160 return match?.[1] !== undefined ? `https://github.com/${match[1]}` : ''
161}
162
163function prStatus(state: unknown, isDraft: unknown): BranchStatus {
164 if (state === 'MERGED') return 'merged'
165 if (state === 'CLOSED') return 'closed'
166 return isDraft === true ? 'draft' : 'open'
167}
168
169function proofComment(item: Evidence, fileName: string, content: string): string {
170 const marker = proofMarker({ id: item.id, kind: item.kind, title: item.title, subtitle: item.subtitle, scenario: item.scenario, detail: item.detail, file: fileName, at: item.at })
171 const body = content !== '' ? content : item.detail
172 const detail = body !== '' ? `\n\n\`\`\`\n${body.replace(/```/g, "'''")}\n\`\`\`` : ''
173 const media = fileName === '' ? '' : item.media === 'image' ? `\n\n` : `\n\n[${fileName}](${fileName})`
174
175 const heading = [item.title || GROUP_OF[item.kind], item.subtitle].filter(Boolean).join(' — ')
176 const proves = item.scenario !== '' ? `\n\nScénario prouvé : _${item.scenario}_` : ''
177
178 return `**Preuve** · ${heading}${proves}${detail}${media}\n\n${marker}`
179}
180
181async function readJson($: EngineInterface, path: string): Promise<unknown> {
182 if (!(await $.fs.exists(path))) return undefined
183 try {
184 return JSON.parse(await $.fs.read(path))
185 } catch {
186 return undefined
187 }
188}
189
190let OPTIONS: Readonly<Record<string, unknown>> = {}
191
192async function repositoryRoot($: EngineInterface): Promise<string> {
193 const ran = await $.process.run(['git', 'rev-parse', '--show-toplevel']).catch(() => undefined)
194 const root = ran !== undefined && ran.exitCode === 0 ? ran.stdout.trim() : ''
195 return root.startsWith('/') ? root : $.session.cwd()
196}
197
198async function savedSettings($: EngineInterface): Promise<Record<string, unknown>> {
199 const path = `${await repositoryRoot($)}/${SETTINGS_FILE}`
200 if (!(await $.fs.exists(path).catch(() => false))) return {}
201 try {
202 const saved: unknown = JSON.parse(await $.fs.read(path))
203 return typeof saved === 'object' && saved !== null ? (saved as Record<string, unknown>) : {}
204 } catch {
205 return {}
206 }
207}
208
209const HUMAN = ['composer', 'bridge']
210
211async function approvedTrackers($: EngineInterface): Promise<string[]> {
212 const saved = await $.store.get('approvedTrackers').catch(() => undefined)
213 return Array.isArray(saved) ? saved.filter((one): one is string => typeof one === 'string') : []
214}
215
216function approvalKey(config: TrackerConfig): string {
217 return `${config.kind} ${config.url} ${config.project}`
218}
219
220async function rawConfig($: EngineInterface): Promise<TrackerConfig> {
221 const saved = await savedSettings($)
222 const url = typeof saved.trackerUrl === 'string' && saved.trackerUrl !== '' ? normalizedUrl(saved.trackerUrl) || 'invalid' : ''
223 const config = configOf({ ...saved, token: '' }, (await trackerToken($, url)).token)
224 return { ...config, url }
225}
226
227function boundToken(value: string): { url: string; token: string } {
228 const [first = '', ...rest] = value.split(/\s+/)
229 const url = rest.length > 0 ? normalizedUrl(first) : ''
230 return url !== '' ? { url, token: rest.join(' ') } : { url: '', token: value }
231}
232
233function tokensOf(value: string): { url: string; token: string }[] {
234 return value
235 .split(/[\n;]/)
236 .map(one => one.trim())
237 .filter(one => one !== '' && !one.startsWith('#'))
238 .map(boundToken)
239}
240
241async function tokenFile($: EngineInterface): Promise<string> {
242 const home = ((await $.env.get('HOME').catch(() => undefined)) || (await $.env.get('USERPROFILE').catch(() => undefined)) || '').replace(/[\\/]+$/, '')
243 if (home === '') return ''
244 const path = `${home}/${TOKEN_FILE}`
245 if (!(await $.fs.exists(path).catch(() => false))) return ''
246 return (await $.fs.read(path).catch(() => '')).trim()
247}
248
249async function trackerToken($: EngineInterface, url: string): Promise<{ url: string; token: string }> {
250 const option = typeof OPTIONS.token === 'string' ? OPTIONS.token : ''
251 const variable = (await $.env.get('ACCEPTANCE_TRACKER_TOKEN').catch(() => undefined)) ?? ''
252 const tokens = [option, variable, await tokenFile($)].flatMap(tokensOf)
253
254 return tokens.find(one => url !== '' && one.url === url) ?? tokens.find(one => one.url === '') ?? { url: '', token: '' }
255}
256
257async function boundToToken($: EngineInterface, config: TrackerConfig): Promise<boolean> {
258 const { url } = await trackerToken($, config.url)
259 return url !== '' && url === config.url
260}
261
262async function isApproved($: EngineInterface, config: TrackerConfig): Promise<boolean> {
263 if (config.url === '') return true
264 if (config.url === 'invalid') return false
265 return (await approvedTrackers($)).includes(approvalKey(config)) || (await boundToToken($, config))
266}
267
268async function trackerConfig($: EngineInterface): Promise<TrackerConfig> {
269 const config = await rawConfig($)
270 return (await isApproved($, config)) ? config : { ...config, url: '', token: '' }
271}
272
273async function configProblem($: EngineInterface, config: TrackerConfig): Promise<string> {
274 const raw = await rawConfig($)
275 if (raw.url === 'invalid') return `${trackerName(config)} : l'URL de ${SETTINGS_FILE} n'est pas une adresse https simple — le jeton n'est envoyé nulle part. Lance toi-même /${SETUP}`
276 if (await isApproved($, raw)) return notConfigured(config)
277 return `${trackerName(config)} : le tracker de ${SETTINGS_FILE} (${new URL(raw.url).origin}) n'a pas été approuvé sur cette machine — le jeton n'y est pas envoyé. Seul l'utilisateur peut l'approuver, en lançant lui-même /${SETUP}`
278}
279
280async function setup($: EngineInterface, args: string, origin: string): Promise<{ text: string }> {
281 if (!HUMAN.includes(origin)) return { text: `/${SETUP} se lance à la main : il décide où part le jeton du tracker.` }
282 const settings = settingsOf(args)
283 if (typeof settings === 'string') return { text: `${settings}\n\nUsage : /${SETUP} <url> [projets YouTrack, ex. CORE,GROWTH] — ou /${SETUP} https://dev.azure.com/<org> <projet> [type de work item]` }
284 await $.fs.write(`${await repositoryRoot($)}/${SETTINGS_FILE}`, `${JSON.stringify(settings, undefined, 2)}\n`)
285 const approved = configOf({ ...settings }, '')
286 await $.store.set('approvedTrackers', [...new Set([...(await approvedTrackers($)), approvalKey(approved)])])
287 const config = configOf({ ...settings, token: '' }, (await trackerToken($, settings.trackerUrl)).token)
288 const token = config.token !== '' ? '' : ` Jeton manquant : écris « <url> <jeton> » dans ~/${TOKEN_FILE}, l'option token du plugin ou la variable ACCEPTANCE_TRACKER_TOKEN.`
289
290 return { text: `${trackerName(config)} configuré : ${config.url} enregistré dans ${SETTINGS_FILE} — à committer pour que chaque session (et le cloud) le retrouve.${token}` }
291}
292
293async function send($: EngineInterface, config: TrackerConfig, request: HttpRequest): Promise<unknown> {
294 if (!isReady(config)) return undefined
295 const response = await $.http.fetch(request.url, { method: request.method, headers: request.headers, body: request.body }).catch(() => undefined)
296 if (response === undefined || !response.ok) return undefined
297 try {
298 return JSON.parse(response.text || '{}')
299 } catch {
300 return {}
301 }
302}
303
304async function curl($: EngineInterface, config: TrackerConfig, lines: string[]): Promise<string> {
305 if (!isReady(config)) return ''
306 if (lines.length === 0) return ''
307 const script = [...curlLines([['header', `Authorization: ${authHeader(config)}`]]), 'silent', 'show-error', 'fail', 'proto = "=https"', ...lines].join('\n')
308 const ran = await $.process.run(['curl', '--config', '-'], { stdin: `${script}\n`, timeoutMs: 120000 }).catch(() => undefined)
309
310 return ran !== undefined && ran.exitCode === 0 ? ran.stdout : ''
311}
312
313async function fetchIssue($: EngineInterface, config: TrackerConfig, id: string): Promise<Issue | undefined> {
314 const response = await send($, config, issueRequest(config, id))
315 if (response === undefined) return undefined
316 const extra = commentsRequest(config, id)
317 const comments = extra === undefined ? undefined : await send($, config, extra)
318
319 return issueOf(config, response, comments)
320}
321
322async function sized($: EngineInterface, path: string): Promise<[number, number]> {
323 const { stdout } = await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', path])
324
325 return [Number(stdout.match(/pixelWidth: (\d+)/)?.[1] ?? 0), Number(stdout.match(/pixelHeight: (\d+)/)?.[1] ?? 0)]
326}
327
328async function withThumbnails($: EngineInterface, item: Evidence): Promise<Evidence> {
329 if (item.media === '' || item.file === '') return item
330 if (item.media !== 'image' && item.media !== 'video') return item
331 const thumb = `${item.file}.thumb.jpg`
332 const large = `${item.file}.large.jpg`
333 const poster = `${item.file}.poster.png`
334 if (item.media === 'image') {
335 await $.process.run(['sips', '-s', 'format', 'jpeg', '-s', 'formatOptions', '60', '-Z', '240', item.file, '--out', thumb])
336 await $.process.run(['sips', '-s', 'format', 'jpeg', '-s', 'formatOptions', '55', '-Z', '760', item.file, '--out', large])
337 await $.process.run(['sips', '-s', 'format', 'png', '-Z', '640', item.file, '--out', poster])
338 } else {
339 const frame = (scale: string, out: string, quality: string[]) =>
340 ['ffmpeg', '-y', '-loglevel', 'error', '-ss', '0.5', '-i', item.file, '-frames:v', '1', '-vf', `scale=${scale}:-2`, ...quality, out]
341 await $.process.run(frame('240', thumb, ['-q:v', '6']), { timeoutMs: 60000 })
342 await $.process.run(frame('760', large, ['-q:v', '8']), { timeoutMs: 60000 })
343 await $.process.run(frame('640', poster, []), { timeoutMs: 60000 })
344 }
345 if (!(await $.fs.exists(thumb))) return item
346 const [width, height] = await sized($, large)
347 const has = async (path: string) => ((await $.fs.exists(path)) ? path : '')
348
349 return { ...item, thumb, large: await has(large), poster: await has(poster), width, height }
350}
351
352async function pushProof($: EngineInterface, config: TrackerConfig, ticket: string, item: Evidence): Promise<Evidence> {
353 const fileName = item.file !== '' ? (item.file.split('/').pop() ?? '') : ''
354 const content = item.media === 'text' ? await $.fs.read(item.file).then(text => text.slice(0, 4000), () => '') : ''
355 let remote = item.remote
356 if (fileName !== '' && remote === '') {
357 remote = uploadedUrlOf(config, await curl($, config, uploadLines(config, ticket, item.file, fileName)))
358 if (remote === '') return item
359 const link = linkAttachmentRequest(config, ticket, remote, fileName)
360 if (link !== undefined) await send($, config, link)
361 }
362 const posted = await send($, config, commentRequest(config, ticket, proofComment(item, fileName, content)))
363
364 return posted === undefined ? { ...item, remote } : { ...item, remote, isSynced: true }
365}
366
367async function evidenceFromComment($: EngineInterface, config: TrackerConfig, text: string, attachments: { name: string; url: string }[], dir: string): Promise<Evidence | undefined> {
368 const parsed = proofOf(text)
369 if (typeof parsed !== 'object' || parsed === null) return undefined
370 const mark = parsed as { id?: unknown; kind?: EvidenceKind; title?: unknown; subtitle?: unknown; scenario?: unknown; detail?: unknown; file?: unknown; at?: unknown }
371 if (!isProofId(mark.id)) return undefined
372 const words = (value: unknown, max: number) => (typeof value === 'string' ? value.slice(0, max) : '')
373 const fileName = safeFileName(words(mark.file, 128))
374 const attachment = fileName === '' ? undefined : attachments.find(one => one.name === fileName)
375 const remote = attachment !== undefined && isTrackerUrl(config, attachment.url) ? attachment.url : ''
376 let file = ''
377 if (remote !== '') {
378 file = `${dir}/${fileName}`
379 if (!(await $.fs.exists(file))) {
380 await $.process.run(['mkdir', '-p', dir])
381 await curl($, config, curlLines([['url', remote], ['output', file]]))
382 if (!(await $.fs.exists(file))) file = ''
383 }
384 }
385 const item: Evidence = {
386 id: mark.id,
387 kind: KINDS.find(one => one === mark.kind) ?? 'other',
388 title: words(mark.title, 160),
389 subtitle: words(mark.subtitle, 160),
390 scenario: words(mark.scenario, 200),
391 detail: words(mark.detail, 600),
392 file,
393 media: file !== '' ? mediaOf(file) : '',
394 thumb: '',
395 poster: '',
396 large: '',
397 width: 0,
398 height: 0,
399 remote,
400 page: '',
401 isSynced: true,
402 at: typeof mark.at === 'number' && Number.isFinite(mark.at) ? mark.at : 0,
403 }
404
405 return withThumbnails($, item)
406}
407
408async function pullTicket($: EngineInterface, config: TrackerConfig, ticket: string, base: string, local: Evidence[]): Promise<[Ticket, string, Evidence[]] | undefined> {
409 const issue = await fetchIssue($, config, ticket)
410 if (issue === undefined) return undefined
411 const known = new Set(local.map(one => one.id))
412 const cwd = await $.session.cwd()
413 const recovered: Evidence[] = []
414 for (const comment of issue.comments) {
415 const mark = proofOf(comment) as { id?: unknown } | undefined
416 if (!isProofId(mark?.id) || known.has(mark.id)) continue
417 const item = await evidenceFromComment($, config, comment, issue.attachments, `${cwd}/${base}/proofs`)
418 if (item !== undefined) recovered.push(item)
419 }
420 const pushed: Evidence[] = []
421 for (const item of local) pushed.push(item.isSynced ? item : await pushProof($, config, ticket, item))
422 const list = [...pushed, ...recovered].sort((a, b) => a.at - b.at)
423
424 return [{ id: ticket, url: issueUrl(config, ticket), summary: issue.summary, context: issue.context }, issue.rules, list]
425}
426
427async function currentName($: EngineInterface): Promise<string> {
428 const { exitCode, stdout } = await $.process.run(['git', 'branch', '--show-current'])
429
430 return exitCode === 0 ? stdout.trim() : ''
431}
432
433async function readLocal($: EngineInterface, name: string): Promise<[string, Evidence[]]> {
434 const base = `.claude/acceptance/${slug(name)}`
435 const text = (await $.fs.exists(`${base}.feature`)) ? await $.fs.read(`${base}.feature`) : ''
436 const proofs = await readJson($, `${base}.evidence.json`)
437
438 return [text, Array.isArray(proofs) ? (proofs as Evidence[]) : []]
439}
440
441async function publish($: EngineInterface, name: string, ticket: Ticket, text: string, list: Evidence[]): Promise<void> {
442 const previous = (await $.state.get(BRANCH)).value ?? NO_BRANCH
443 if (previous.name !== name) await $.state.set(BRANCH, { ...NO_BRANCH, name, status: name === '' ? 'none' : 'clean' })
444 if (JSON.stringify((await $.state.get(TICKET)).value) !== JSON.stringify(ticket)) await $.state.set(TICKET, ticket)
445 if ((await $.state.get(CRITERIA)).value !== text) await $.state.set(CRITERIA, text)
446 if (JSON.stringify((await $.state.get(EVIDENCE)).value ?? []) !== JSON.stringify(list)) await $.state.set(EVIDENCE, list)
447}
448
449async function refreshLocal($: EngineInterface): Promise<string> {
450 const name = await currentName($)
451 const [text, list] = await readLocal($, name)
452 const config = await trackerConfig($)
453 const id = ticketOf(config, name) ?? ''
454 const held = (await $.state.get(TICKET)).value ?? NO_TICKET
455 const ticket: Ticket = id === '' ? NO_TICKET : held.id === id ? held : { id, url: issueUrl(config, id), summary: '', context: '' }
456 await publish($, name, ticket, text, list)
457 const pages = await readPages($, `.claude/acceptance/${slug(name)}`)
458 const links: Pages = { ticket: pages.ticket, rules: pages.rules, proofs: pages.proofs }
459 if (JSON.stringify((await $.state.get(PAGES)).value) !== JSON.stringify(links)) await $.state.set(PAGES, links)
460
461 return name
462}
463
464async function syncTicket($: EngineInterface, name: string): Promise<void> {
465 const config = await trackerConfig($)
466 const id = ticketOf(config, name)
467 if (id === '' || !isReady(config)) return
468 const base = `.claude/acceptance/${slug(name)}`
469 const [localText, local] = await readLocal($, name)
470 const pulled = await pullTicket($, config, id, base, local)
471 if (pulled === undefined) return
472 const [ticket, rules, list] = pulled
473 const text = rules !== '' ? `${rules}\n` : localText
474 if (text !== localText) await $.fs.write(`${base}.feature`, text)
475 if (JSON.stringify(list) !== JSON.stringify(local)) await $.fs.write(`${base}.evidence.json`, `${JSON.stringify(list, undefined, 2)}\n`)
476 await publish($, name, ticket, text, list)
477}
478
479async function refreshBranch($: EngineInterface, name: string): Promise<void> {
480 if (name === '') return
481 const dirty = (await $.process.run(['git', 'status', '--porcelain'])).stdout.trim() !== ''
482 const remote = repoUrl((await $.process.run(['git', 'remote', 'get-url', 'origin'])).stdout)
483 const pr = await $.process.run(['gh', 'pr', 'view', '--json', 'url,state,isDraft,number'], { timeoutMs: 8000 }).catch(() => undefined)
484 const parsed: unknown = (() => {
485 try {
486 return pr?.exitCode === 0 && pr.stdout.trim() !== '' ? JSON.parse(pr.stdout) : undefined
487 } catch {
488 return undefined
489 }
490 })()
491 const view = typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : undefined
492 const status: BranchStatus = view !== undefined ? prStatus(view.state, view.isDraft) : dirty ? 'dirty' : 'clean'
493 const url = typeof view?.url === 'string' ? view.url : remote !== '' ? `${remote}/tree/${name}` : ''
494 const info: BranchInfo = { name, status, url, pr: typeof view?.number === 'number' ? view.number : 0 }
495 if (JSON.stringify((await $.state.get(BRANCH)).value) !== JSON.stringify(info)) await $.state.set(BRANCH, info)
496}
497
498type Draft = { kind: EvidenceKind; title: string; subtitle: string; scenario: string; detail: string; source: string }
499
500async function keep($: EngineInterface, draftProof: Draft): Promise<void> {
501 let proof = draftProof
502 const { kind, title, scenario, detail, source } = proof
503 const name = await currentName($)
504 const base = `.claude/acceptance/${slug(name)}`
505 const at = await $.clock.now()
506 const dir = `${await $.session.cwd()}/${base}/proofs`
507 await $.process.run(['mkdir', '-p', dir])
508 let file = ''
509 if (kind === 'artifact' && source !== '') {
510 const html = await $.fs.read(source).catch(() => '')
511 const named = html.match(/<title>([^<]+)<\/title>/i)?.[1]?.trim()
512 if (named !== undefined && named !== '') proof = { ...proof, subtitle: named }
513 } else if (source !== '' && KEPT.test(source)) {
514 const target = `${dir}/${at}-${source.split('/').pop() ?? 'proof'}`
515 file = (await $.process.run(['cp', source, target])).exitCode === 0 ? target : ''
516 }
517 if (file === '' && kind !== 'artifact') {
518 file = `${dir}/${at}-${fileSlug([title || GROUP_OF[kind], proof.subtitle].join(' '))}.txt`
519 await $.fs.write(file, textProof(proof))
520 }
521 const draft: Evidence = {
522 id: `${at}`,
523 kind,
524 title: title.slice(0, 160),
525 subtitle: proof.subtitle.slice(0, 160),
526 scenario: scenario.slice(0, 200),
527 detail: detail.slice(0, 600),
528 file,
529 media: mediaOf(file),
530 thumb: '',
531 poster: '',
532 large: '',
533 width: 0,
534 height: 0,
535 remote: '',
536 page: '',
537 isSynced: false,
538 at,
539 }
540 const thumbed = await withThumbnails($, draft)
541 const local = { ...thumbed, page: '' }
542 const config = await trackerConfig($)
543 const ticket = ticketOf(config, name)
544 const item = ticket !== '' && isReady(config) ? await pushProof($, config, ticket, local) : local
545 const previous = await readJson($, `${base}.evidence.json`)
546 const list = [...(Array.isArray(previous) ? (previous as Evidence[]) : []), item].slice(-200)
547 await $.fs.write(`${base}.evidence.json`, `${JSON.stringify(list, undefined, 2)}\n`)
548 await $.state.set(EVIDENCE, list)
549}
550
551function detect(tool: string, input: Record<string, unknown>, text: string): Draft | undefined {
552 const found = detectRaw(tool, input, text)
553
554 return found === undefined ? undefined : { kind: found[0], title: '', subtitle: found[1], scenario: '', detail: found[2], source: found[3] }
555}
556
557function detectRaw(tool: string, input: Record<string, unknown>, text: string): [EvidenceKind, string, string, string] | undefined {
558 const command = typeof input.command === 'string' ? input.command : ''
559 const filePath = typeof input.file_path === 'string' ? input.file_path : ''
560 const files = Array.isArray(input.files) ? input.files.filter((one): one is string => typeof one === 'string') : []
561 const url = text.match(/https:\/\/claude\.ai\/\S*artifact\S*/)?.[0] ?? ''
562 const runsCurl = /(?:^|[;&|(]\s*|&&\s*)curl\s/m.test(command) && !/Authorization:|youtrack\.cloud|dev\.azure\.com|visualstudio\.com/i.test(command)
563 if (tool === 'Bash' && runsCurl) return ['curl', command, `$ ${command}\n\n${text}`, '']
564 if (tool === 'Bash' && TEST_RUN.test(command)) return ['test', command, `$ ${command}\n\n${text.slice(-6000)}`, '']
565 if (tool === 'Bash' && /screencapture|simctl io .*(screenshot|recordVideo)|playwright.*screenshot/.test(command)) {
566 const shot = command.match(/(\S+\.(png|jpe?g|mp4|mov))/i)?.[1] ?? ''
567 return [VIDEO.test(shot) ? 'video' : 'capture', shot.split('/').pop() ?? command, '', shot]
568 }
569 if (tool === 'Read' && (IMAGE.test(filePath) || VIDEO.test(filePath)) && !filePath.includes('.claude/acceptance/')) {
570 return [VIDEO.test(filePath) ? 'video' : 'capture', filePath.split('/').pop() ?? filePath, '', filePath]
571 }
572 const named = typeof input.title === 'string' && input.title !== '' ? input.title : (filePath.split('/').pop() ?? '').replace(/\.[a-z]+$/i, '')
573 const isPublish = (input.action === undefined || input.action === 'publish') && input.url === undefined && input.type_url === undefined
574 if (tool === 'Artifact' && isPublish && filePath !== '' && !filePath.includes('/.claude/acceptance/') && input.asset !== true && url !== '') return ['artifact', named || url, url, filePath]
575 if (tool === 'SendUserFile' && files.length > 0) return ['file', files.join(', '), typeof input.caption === 'string' ? input.caption : '', files[0] ?? '']
576 return undefined
577}
578
579async function base64($: EngineInterface, path: string): Promise<string> {
580 if (path === '') return ''
581 const cached = thumbnails.get(path) ?? (await $.fs.read(path, { as: 'bytes' }).then(bytes => bytes.base64, () => ''))
582 thumbnails.set(path, cached)
583
584 return cached
585}
586
587function md(text: string): string {
588 return text.replace(/([\\`*_[\]#<>|])/g, '\\$1')
589}
590
591const BLUE_CHIP = { fill: 'rgba(77,137,220,0.16)', hover: 'rgba(77,137,220,0.30)' }
592
593type PageKind = keyof Pages
594
595const PAGE_ICONS: Record<PageKind, string> = { ticket: 'ticket', rules: 'checklist', proofs: 'image' }
596
597function hashOf(text: string): string {
598 let hash = 0
599 for (let at = 0; at < text.length; at += 1) hash = (hash * 31 + text.charCodeAt(at)) | 0
600 return `${text.length}-${hash >>> 0}`
601}
602
603async function publishArtifact($: EngineInterface, kind: PageKind, path: string, url: string, files: Record<string, string>, title: string): Promise<string> {
604 const input = url !== ''
605 ? { tool: 'Artifact' as const, url, file_path: path, files }
606 : { tool: 'Artifact' as const, file_path: path, files, icon: PAGE_ICONS[kind], description: title }
607 const ran = await $.tool.call(input).catch(() => undefined)
608 const text = ran === undefined ? '' : (ran.text ?? (typeof ran.result === 'string' ? ran.result : ''))
609
610 return text.match(/https:\/\/claude\.ai\/(?:code\/)?artifact\/[A-Za-z0-9_-]+/)?.[0] ?? url
611}
612
613async function readPages($: EngineInterface, base: string): Promise<Pages & { hashes: Record<string, string> }> {
614 const stored = await readJson($, `${base}.pages.json`)
615 const value = typeof stored === 'object' && stored !== null ? (stored as Partial<Pages & { hashes: Record<string, string> }>) : {}
616
617 return { ticket: value.ticket ?? '', rules: value.rules ?? '', proofs: value.proofs ?? '', hashes: value.hashes ?? {} }
618}
619
620async function pageContent($: EngineInterface, name: string, links: PageLinks): Promise<{ ticket: string; rules: string; proofs: string; files: Record<string, string> }> {
621 const branch = (await $.state.get(BRANCH)).value ?? NO_BRANCH
622 const held = (await $.state.get(TICKET)).value ?? NO_TICKET
623 const rules = parseRules((await $.state.get(CRITERIA)).value ?? '')
624 const evidence = (await $.state.get(EVIDENCE)).value ?? []
625 const proofsOf = (scenario: string) => evidence.filter(proof => sameName(proof.scenario, scenario))
626 const scenarios = rules.subjects.flatMap(subject => subject.scenarios)
627 const info: PageTicket = {
628 id: labelOf(held.id),
629 tracker: trackerName(await trackerConfig($)),
630 url: held.url,
631 summary: held.summary,
632 context: held.context,
633 branch: name,
634 branchUrl: branch.url,
635 status: branch.status,
636 done: scenarios.filter(one => proofsOf(one.name).length > 0).length,
637 total: scenarios.length,
638 proofs: evidence.length,
639 }
640 const subjects: PageSubject[] = rules.subjects.map(subject => ({
641 name: subject.name || 'Scénarios',
642 scenarios: subject.scenarios.map(one => ({ name: one.name, steps: one.steps, proofs: proofsOf(one.name).map(proof => ({ id: proof.id, label: proof.subtitle || GROUP_OF[proof.kind] })) })),
643 }))
644 const files: Record<string, string> = {}
645 const proofs: PageProof[] = []
646 for (const item of evidence) {
647 const fileName = item.file.split('/').pop() ?? ''
648 if ((item.media === 'image' || item.media === 'video') && item.file !== '') files[`media/${fileName}`] = item.file
649 proofs.push({
650 id: item.id,
651 kind: item.kind,
652 media: item.kind === 'artifact' ? 'artifact' : item.media === 'image' || item.media === 'video' ? item.media : 'text',
653 group: item.title || GROUP_OF[item.kind],
654 subtitle: item.subtitle || GROUP_OF[item.kind],
655 scenario: item.scenario,
656 src: item.media === 'image' || item.media === 'video' ? `media/${fileName}` : fileName,
657 text: item.media === 'text' ? await $.fs.read(item.file).catch(() => item.detail) : item.detail,
658 link: item.kind === 'artifact' ? linkOf(item) : '',
659 })
660 }
661
662 return { ticket: ticketPage(info, links), rules: rulesPage(info, rules.feature, subjects, links), proofs: proofsPage(info, proofs, links), files }
663}
664
665async function publishPages($: EngineInterface, name: string): Promise<void> {
666 const held = (await $.state.get(TICKET)).value ?? NO_TICKET
667 const config = await trackerConfig($)
668 if (held.id === '' || (!isReady(config) && held.summary === '')) return
669 const base = `.claude/acceptance/${slug(name)}`
670 const cwd = await $.session.cwd()
671 const stored = await readPages($, base)
672 const links: Pages = { ticket: stored.ticket, rules: stored.rules, proofs: stored.proofs }
673 const kinds: PageKind[] = ['ticket', 'rules', 'proofs']
674 if (kinds.some(kind => links[kind] === '')) {
675 const draft = await pageContent($, name, links)
676 for (const kind of kinds) {
677 if (links[kind] !== '') continue
678 const path = `${cwd}/${base}.${kind}.html`
679 await $.fs.write(path, draft[kind])
680 links[kind] = await publishArtifact($, kind, path, '', kind === 'proofs' ? draft.files : {}, `${labelOf(held.id)} — ${kind === 'ticket' ? 'Ticket' : kind === 'rules' ? 'Règles' : 'Preuves'}`)
681 }
682 }
683 const content = await pageContent($, name, links)
684 const hashes: Record<string, string> = { ...stored.hashes }
685 for (const kind of kinds) {
686 const hash = hashOf(content[kind] + (kind === 'proofs' ? Object.keys(content.files).join(',') : ''))
687 if (hashes[kind] === hash || links[kind] === '') continue
688 const path = `${cwd}/${base}.${kind}.html`
689 await $.fs.write(path, content[kind])
690 await publishArtifact($, kind, path, links[kind], kind === 'proofs' ? content.files : {}, '')
691 hashes[kind] = hash
692 }
693 await $.fs.write(`${base}.pages.json`, `${JSON.stringify({ ...links, hashes }, undefined, 2)}\n`)
694 if (JSON.stringify((await $.state.get(PAGES)).value) !== JSON.stringify(links)) await $.state.set(PAGES, links)
695}
696
697async function openArtifact($: EngineInterface, url: string): Promise<void> {
698 await $.tool.call({ tool: 'Artifact', action: 'open', url }).catch(() => $.ui.toast(`Impossible d’ouvrir ${url}`))
699}
700
701async function openFile($: EngineInterface, file: string): Promise<void> {
702 const ran = await $.process.run(['open', file]).catch(() => undefined)
703 if (ran === undefined || ran.exitCode !== 0) $.ui.toast(`Impossible d’ouvrir ${file}`)
704}
705
706async function browse($: EngineInterface, url: string): Promise<void> {
707 await $.tool.call({ tool: 'mcp__Claude_Browser__preview_start', url }).catch(() => $.ui.toast(`Navigateur intégré indisponible : ${url}`))
708}
709
710function proofLink(item: Evidence): string {
711 if (item.kind === 'artifact') return linkOf(item)
712 return item.page !== '' ? item.page : fileUrl(item.file)
713}
714
715function fileUrl(path: string): string {
716 return `file://${encodeURI(path)}`
717}
718
719function scenarioMarkdown(name: string, steps: string[], proofs: Evidence[]): string {
720 const lines = steps.map(step => {
721 const keyword = step.match(/^\S+/)?.[0] ?? ''
722 return ` *${md(keyword)}*${md(step.slice(keyword.length))}`
723 })
724 if (proofs.length === 0) lines.push(' _En attente de preuve_')
725
726 return `${proofs.length > 0 ? '✓' : '○'} **${md(name)}** \n${lines.join(' \n')}`
727}
728
729const CRITERIA_TOOL = 'save_acceptance_criteria'
730const CREATE_TOOL = 'create_ticket'
731const USE_TOOL = 'use_ticket'
732const BRANCH_CREATE = /git\s+(?:checkout\s+-b|switch\s+(?:-c|--create)|branch(?!\s+-)|worktree\s+add\s+(?:\S+\s+)*?-b)\s+([^\s;&|]+)/
733
734function criteriaPath(branch: string): string {
735 return `.claude/acceptance/${slug(branch)}.feature`
736}
737
738async function artifactLinks($: EngineInterface, branch: string, label: string): Promise<string> {
739 const stored = await readPages($, `.claude/acceptance/${slug(branch)}`)
740 const lines = [
741 stored.ticket !== '' ? `- ${label} — Ticket : ${stored.ticket}` : '',
742 stored.rules !== '' ? `- ${label} — Règles : ${stored.rules}` : '',
743 stored.proofs !== '' ? `- ${label} — Preuves : ${stored.proofs}` : '',
744 ].filter(Boolean)
745
746 return label === '' || lines.length === 0 ? '' : `\n\nArtefacts du ticket, à citer dans le résumé final :\n${lines.join('\n')}`
747}
748
749function notConfigured(config: TrackerConfig): string {
750 const missing = [config.url === '' ? `URL (/${SETUP})` : '', config.token === '' ? `jeton (option token, ACCEPTANCE_TRACKER_TOKEN ou ~/${TOKEN_FILE})` : '', config.kind === 'azure-devops' && config.project === '' ? `projet (/${SETUP})` : ''].filter(Boolean)
751 return `${trackerName(config)} n'est pas configuré : ${missing.join(', ')}`
752}
753
754async function required($: EngineInterface, config: TrackerConfig, request: HttpRequest): Promise<unknown> {
755 if (!isReady(config)) throw new Error(notConfigured(config))
756 const response = await $.http.fetch(request.url, { method: request.method, headers: request.headers, body: request.body })
757 if (!response.ok) throw new Error(`${trackerName(config)} ${response.status} : ${response.text.slice(0, 200)}`)
758
759 return JSON.parse(response.text || '{}')
760}
761
762async function pushRules($: EngineInterface, config: TrackerConfig, id: string, feature: string): Promise<void> {
763 const issue = issueOf(config, await required($, config, issueRequest(config, id)), undefined)
764 await required($, config, descriptionRequest(config, id, withRules(config, issue.description, feature)))
765}
766
767async function projectIdOf($: EngineInterface, config: TrackerConfig, project: string): Promise<string> {
768 if (config.kind === 'azure-devops') return config.project
769 const list = await required($, config, projectsRequest(config))
770 const found = (Array.isArray(list) ? (list as { id?: string; shortName?: string }[]) : []).find(one => one.shortName === project)
771
772 return found?.id ?? ''
773}
774
775async function switchTo($: EngineInterface, branch: string): Promise<string> {
776 const exists = (await $.process.run(['git', 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])).exitCode === 0
777 const ran = await $.process.run(exists ? ['git', 'switch', branch] : ['git', 'switch', '-c', branch])
778
779 return ran.exitCode === 0 ? `branche ${branch} ${exists ? 'reprise' : 'créée'}` : `branche ${branch} non basculée : ${ran.stderr.trim()}`
780}
781
782function projectHint(config: TrackerConfig): string {
783 if (config.kind === 'azure-devops') return `dans le projet Azure DevOps ${config.project || '(à configurer)'}`
784 if (config.projects.length === 0) return 'dans le projet YouTrack qui convient (demande en cas de doute)'
785
786 return `dans l'un des projets ${config.projects.join(', ')} (${config.projects[0] ?? ''} par défaut ; demande en cas de doute)`
787}
788
789function policy(config: TrackerConfig): string {
790 const example = `feature/${exampleId(config)}`
791
792 return `# Critères d'acceptation, ticket et branche (plugin acceptance)
793
794Avant d'écrire du code pour une demande de fonctionnalité, de correction ou de changement de comportement :
795
7961. Reformule la demande en critères d'acceptation Gherkin (Feature / Rule / Scenario / Given / When / Then, mots-clés anglais), cas nominal, cas limites et cas d'erreur inclus. Regroupe les scénarios par sujet sous des \`Rule: <sujet>\` : l'artefact Règles en fait une checklist par sujet.
7972. Signale les ambiguïtés et pose les questions qui changent les critères ; propose pour chacune une réponse par défaut.
7983. Négocie : présente les scénarios, ajuste-les selon les retours, et attends un accord explicite avant d'implémenter.
7994. Ticket (${trackerName(config)}) : si la branche ne porte pas de numéro de ticket, crée-le avec mcp__acceptance__${CREATE_TOOL} ${projectHint(config)}, avec un contexte complet : la demande, le pourquoi, les décisions de la négociation. Pour reprendre un ticket existant, mcp__acceptance__${USE_TOOL}. Les deux basculent sur la branche feature/<ID> (fix/<ID> pour un bug), par exemple ${example} : ne crée jamais de branche sans numéro de ticket.
8005. Enregistre les règles validées avec mcp__acceptance__${CRITERIA_TOOL} : elles vont dans la description du ticket. Renégocie et réenregistre si elles changent.
8016. Chaque preuve passe par mcp__acceptance__${TOOL} avec un titre de groupe (surface et contexte : « Web — Calendrier mobile »), un sous-titre (la variante : « sans publication »), le nom exact du Scenario qu'elle satisfait (il se coche dans Règles) et le chemin du fichier pour une capture ou une vidéo. Elle est jointe au ticket.
8027. À la fin, rapporte scénario par scénario s'il est satisfait et par quelle preuve. Ton résumé final liste toujours les artefacts du ticket, chacun par son nom exact et son lien : « <ID> — Ticket », « <ID> — Règles », « <ID> — Preuves ».
803
804Une question, une lecture ou une modification triviale n'exige ni négociation ni ticket.`
805}
806
807async function registerInputTools($: EngineInterface, config: TrackerConfig): Promise<void> {
808 const name = trackerName(config)
809 await $.tool.register({
810 name: CRITERIA_TOOL,
811 description: `Enregistre les critères d'acceptation Gherkin validés pour la branche courante, localement et dans la description de son ticket ${name} (remplace la version précédente).`,
812 inputSchema: {
813 type: 'object',
814 properties: { feature: { type: 'string', description: 'Le texte Gherkin complet (Feature, Scenario, Given/When/Then).' } },
815 required: ['feature'],
816 },
817 isDeferred: false,
818 })
819 const project = config.kind === 'youtrack' && config.projects.length > 0 ? { type: 'string', enum: config.projects } : { type: 'string', description: config.kind === 'youtrack' ? 'Le nom court du projet YouTrack.' : 'Ignoré : le projet Azure DevOps vient de la configuration.' }
820 await $.tool.register({
821 name: CREATE_TOOL,
822 description: `Crée le ticket ${name} de la demande (contexte complet, plus les règles déjà validées) et bascule sur la branche feature/<ID> ou fix/<ID>.`,
823 inputSchema: {
824 type: 'object',
825 properties: {
826 project,
827 summary: { type: 'string', description: 'Titre court du ticket.' },
828 context: { type: 'string', description: 'Markdown : la demande, le pourquoi, les décisions prises, les contraintes.' },
829 kind: { type: 'string', enum: ['feature', 'fix'], description: 'Préfixe de la branche ; feature par défaut.' },
830 },
831 required: config.kind === 'youtrack' ? ['project', 'summary', 'context'] : ['summary', 'context'],
832 },
833 isDeferred: false,
834 })
835 await $.tool.register({
836 name: USE_TOOL,
837 description: `Reprend un ticket ${name} existant (ex. ${exampleId(config)}) : bascule sur sa branche feature/<ID> ou fix/<ID> ; règles et preuves sont relues depuis le ticket.`,
838 inputSchema: {
839 type: 'object',
840 properties: { id: { type: 'string' }, kind: { type: 'string', enum: ['feature', 'fix'] } },
841 required: ['id'],
842 },
843 isDeferred: false,
844 })
845 await $.command.register({ name: SETUP, description: "Configure le tracker de tickets du dépôt : /acceptance-setup <url> [projets]" })
846 await $.command.register({ name: 'criteres', description: "Affiche le ticket et les critères d'acceptation de la branche courante" })
847}
848
849async function saveCriteria($: EngineInterface, raw: unknown): Promise<{ result: string } | { deny: string }> {
850 const feature = typeof raw === 'string' ? raw.trim() : ''
851 if (!/^\s*(Feature|Fonctionnalité)\s*:/m.test(feature) || !/^\s*(Scenario|Scénario)/m.test(feature)) {
852 return { deny: 'Le texte doit contenir une ligne "Feature:" et au moins un "Scenario:".' }
853 }
854 const config = await trackerConfig($)
855 const branch = await currentName($)
856 const path = criteriaPath(branch)
857 await $.fs.write(path, `${feature}\n`)
858 const ticket = ticketOf(config, branch)
859 if (ticket === '') return { result: `Critères enregistrés dans ${path}. Aucun ticket : crée-le pour qu'ils y soient copiés.` }
860 const label = displayId(config, ticket)
861 const synced = await pushRules($, config, ticket, feature).then(
862 () => `et dans la description de ${label}`,
863 (error: unknown) => `mais pas dans ${label} (${String(error)})`,
864 )
865
866 return { result: `Critères enregistrés dans ${path} ${synced}.` }
867}
868
869async function createTicket($: EngineInterface, input: Record<string, unknown>): Promise<{ result: string } | { deny: string }> {
870 const config = await trackerConfig($)
871 const summary = typeof input.summary === 'string' ? input.summary.trim() : ''
872 const context = typeof input.context === 'string' ? input.context.trim() : ''
873 const project = typeof input.project === 'string' ? input.project.trim() : (config.projects[0] ?? '')
874 if (summary === '' || context === '') return { deny: 'Titre et contexte sont obligatoires.' }
875 const branch = await currentName($)
876 const existing = ticketOf(config, branch)
877 if (existing !== '') return { deny: `La branche ${branch} a déjà son ticket ${displayId(config, existing)}.` }
878 const projectId = await projectIdOf($, config, project).catch(() => '')
879 if (projectId === '') return { deny: `Projet introuvable : ${project || '(aucun)'}${config.projects.length > 0 ? ` (${config.projects.join(', ')})` : ''}.` }
880 const local = criteriaPath(branch)
881 const feature = (await $.fs.exists(local)) ? await $.fs.read(local) : ''
882 const created = await required($, config, createRequest(config, projectId, summary, descriptionFor(config, context, feature))).catch((error: unknown) => String(error))
883 const id = typeof created === 'string' ? '' : createdIdOf(config, created)
884 if (id === '') return { deny: `Ticket non créé : ${typeof created === 'string' ? created : "aucun identifiant rendu"}.` }
885 const target = `${input.kind === 'fix' ? 'fix' : 'feature'}/${id}`
886 const switched = await switchTo($, target)
887 if (feature !== '') await $.fs.write(criteriaPath(target), feature)
888
889 return { result: `Ticket ${displayId(config, id)} créé (${issueUrl(config, id)}) ; ${switched}.` }
890}
891
892async function useTicket($: EngineInterface, input: Record<string, unknown>): Promise<{ result: string } | { deny: string }> {
893 const config = await trackerConfig($)
894 const id = typeof input.id === 'string' ? input.id.trim().replace(/^#/, '') : ''
895 if (!isTicketId(config, id)) return { deny: `L'identifiant doit ressembler à ${exampleId(config)}.` }
896 if (!isReady(config)) return { deny: await configProblem($, config) }
897 const issue = await required($, config, issueRequest(config, id)).then(
898 response => issueOf(config, response, undefined),
899 (error: unknown) => String(error),
900 )
901 if (typeof issue === 'string') return { deny: `Ticket ${displayId(config, id)} inaccessible sur ${config.url} : ${issue}` }
902
903 return { result: `Ticket ${displayId(config, id)} : ${issue.summary} ; ${await switchTo($, `${input.kind === 'fix' ? 'fix' : 'feature'}/${id}`)}.` }
904}
905
906export const register: Register = (on, options) => {
907 OPTIONS = options
908
909 on('session.start', async ($, e, next) => {
910 await registerInputTools($, await trackerConfig($))
911 await $.tool.register({
912 name: TOOL,
913 description:
914 "Ajoute une preuve (capture, vidéo, cURL, artefact, fichier…) qui démontre qu'un critère d'acceptation est satisfait : conservée localement et jointe au ticket de la branche. Nomme le scénario prouvé dans le titre.",
915 inputSchema: {
916 type: 'object',
917 properties: {
918 kind: { type: 'string', enum: KINDS },
919 title: { type: 'string', description: 'Titre de groupe : la surface et le contexte, ex. « Web — Calendrier mobile », « Web — Calendrier tablette », « API — Publication ».' },
920 subtitle: { type: 'string', description: 'Sous-titre : la variante montrée, ex. « sans publication », « avec 2 publications le même jour ».' },
921 scenario: { type: 'string', description: 'Le nom exact du Scenario Gherkin que la preuve satisfait (coche la règle).' },
922 detail: { type: 'string', description: 'Pour une preuve texte (cURL, test) : la commande et sa sortie complète, enregistrées dans un .txt joint au ticket. Pour un artefact : son URL.' },
923 path: { type: 'string', description: 'Le fichier de la preuve : obligatoire pour une capture ou une vidéo (enregistre-la d’abord sur disque). Absent, detail devient un fichier .txt, sauf pour un artefact qui reste un lien.' },
924 },
925 required: ['kind', 'title'],
926 },
927 isDeferred: false,
928 })
929 const name = await refreshLocal($)
930 await syncTicket($, name)
931 await refreshBranch($, name)
932 await publishPages($, name)
933
934 return next(e)
935 })
936
937 on('tool.call', { tool: `mcp__acceptance__${TOOL}` }, async ($, e) => {
938 const kind = KINDS.find(one => one === e.kind) ?? 'other'
939 const title = typeof e.title === 'string' ? e.title : ''
940 if (title === '') return { deny: 'Le titre est obligatoire.' }
941 const text = (value: unknown) => (typeof value === 'string' ? value : '')
942 await keep($, { kind, title, subtitle: text(e.subtitle), scenario: text(e.scenario), detail: text(e.detail), source: text(e.path) })
943 await refreshLocal($)
944 const list = (await $.state.get(EVIDENCE)).value ?? []
945 const isSynced = list[list.length - 1]?.isSynced === true
946
947 return { result: isSynced ? 'Preuve conservée et jointe au ticket.' : 'Preuve conservée ; elle rejoindra le ticket dès que la branche en aura un.' }
948 })
949
950 on('tool.call', async ($, e, next) => {
951 const ran = await next(e)
952 const before = ((await $.state.get(BRANCH)).value ?? NO_BRANCH).name
953 if (ran.deny === undefined && ran.isError === undefined && !e.tool.startsWith('mcp__acceptance__')) {
954 const found = detect(e.tool, e as unknown as Record<string, unknown>, ran.text ?? (typeof ran.result === 'string' ? ran.result : ''))
955 if (found !== undefined) await keep($, found)
956 }
957 const name = await refreshLocal($)
958 if (name !== before) await syncTicket($, name)
959 if (e.tool === 'Bash' || name !== before) await refreshBranch($, name)
960
961 return ran
962 })
963
964 on('prompt.submit', async ($, e, next) => {
965 if (e.origin !== undefined) return next(e)
966 const config = await trackerConfig($)
967 const branch = await currentName($)
968 const ticket = ticketOf(config, branch)
969 const label = displayId(config, ticket)
970 const rules = (await $.fs.exists(criteriaPath(branch))) ? 'Des règles validées existent (artefact Règles, description du ticket).' : 'Aucune règle validée.'
971 const tracked = ticket !== '' ? `Branche ${branch}, ticket ${label} (${issueUrl(config, ticket)}).` : `Branche ${branch || '(détachée)'} sans ticket.`
972 const setup = isReady(config) ? '' : `\n\n${await configProblem($, config)}. Propose à l'utilisateur de lancer lui-même /${SETUP} <url> [projets].`
973
974 return next({ ...e, context: [...(e.context ?? []), `${policy(config)}\n\n${tracked} ${rules}${await artifactLinks($, branch, label)}${setup}`] })
975 })
976
977 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
978 const command = typeof e.command === 'string' ? e.command : ''
979 const created = command.match(BRANCH_CREATE)?.[1]
980 const config = await trackerConfig($)
981 if (created !== undefined && ticketOf(config, created) === '') {
982 return { deny: `Une branche porte le numéro de son ticket (feature/${exampleId(config)}) : crée d'abord le ticket avec mcp__acceptance__${CREATE_TOOL}.` }
983 }
984
985 return next(e)
986 })
987
988 on('tool.call', { tool: `mcp__acceptance__${CRITERIA_TOOL}` }, async ($, e) => saveCriteria($, e.feature))
989
990 on('tool.call', { tool: `mcp__acceptance__${CREATE_TOOL}` }, async ($, e) => createTicket($, e as unknown as Record<string, unknown>))
991
992 on('tool.call', { tool: `mcp__acceptance__${USE_TOOL}` }, async ($, e) => useTicket($, e as unknown as Record<string, unknown>))
993
994 on('command.run', { command: SETUP }, async ($, e) => setup($, e.args, e.origin.kind))
995
996 on('command.run', { command: 'criteres' }, async $ => {
997 const config = await trackerConfig($)
998 const branch = await currentName($)
999 const path = criteriaPath(branch)
1000 const ticket = ticketOf(config, branch)
1001 const rules = (await $.fs.exists(path)) ? await $.fs.read(path) : `Aucun critère enregistré (${path}).`
1002
1003 return { text: `${ticket !== '' ? `Ticket ${displayId(config, ticket)} : ${issueUrl(config, ticket)}\n\n` : ''}${rules}` }
1004 })
1005
1006 on('turn.complete', async ($, e, next) => {
1007 const name = await refreshLocal($)
1008 await syncTicket($, name)
1009 await refreshBranch($, name)
1010 await publishPages($, name)
1011
1012 return next(e)
1013 })
1014
1015 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1016 if (e.props.hasSurvey) return next(e)
1017 const ticket = (await $.state.get(TICKET)).value ?? NO_TICKET
1018 const pages = (await $.state.get(PAGES)).value ?? NO_PAGES
1019 const rules = parseRules((await $.state.get(CRITERIA)).value ?? '')
1020 const evidence = (await $.state.get(EVIDENCE)).value ?? []
1021 const all = rules.subjects.flatMap(subject => subject.scenarios)
1022 const done = all.filter(one => evidence.some(proof => sameName(proof.scenario, one.name))).length
1023 if (ticket.id === '' && all.length === 0 && evidence.length === 0) return next(e)
1024 const table = $.ui.resolve(e)
1025 const { Box, Button } = table
1026 const Markdown = 'Markdown' in table ? table.Markdown : undefined
1027 const open = (url: string, fallback: string, title: string) => () =>
1028 void (url !== '' ? openArtifact($, url) : fallback !== '' ? $.ui.open({ id: fallback, title }) : browse($, ticket.url))
1029 const link = (key: string, url: string, label: string, press: () => void) =>
1030 Markdown !== undefined && url !== '' ? (
1031 <Box key={key}>
1032 <Markdown key={`${key}-link`} text={`[${md(label)}](${url})`} />
1033 </Box>
1034 ) : (
1035 <Button key={key} variant="secondary" onPress={press}>
1036 {label}
1037 </Button>
1038 )
1039
1040 return (
1041 <Box flexDirection="row" gap={1} alignItems="center" flexWrap="wrap">
1042 {ticket.id !== '' && link('ticket', pages.ticket, `Ticket ${labelOf(ticket.id)}`, open(pages.ticket, '', 'Ticket'))}
1043 <Box flexGrow={1} />
1044 {all.length > 0 && link('rules', pages.rules, `Règles ${done}/${all.length}`, open(pages.rules, RULES, 'Règles'))}
1045 {evidence.length > 0 && link('proofs', pages.proofs, `Preuves ${evidence.length}`, open(pages.proofs, PROOFS, 'Preuves'))}
1046 </Box>
1047 )
1048 })
1049
1050 on('ui.render', { component: 'Pane', requestId: RULES }, async ($, e) => {
1051 const table = $.ui.resolve(e)
1052 const { Box, Button, Text } = table
1053 const Svg = 'Svg' in table ? table.Svg : undefined
1054 const Markdown = 'Markdown' in table ? table.Markdown : undefined
1055 const rules = parseRules((await $.state.get(CRITERIA)).value ?? '')
1056 const evidence = (await $.state.get(EVIDENCE)).value ?? []
1057 const ticket = (await $.state.get(TICKET)).value ?? NO_TICKET
1058 const collapsed = (await $.state.get(COLLAPSED)).value ?? []
1059 const proofsOf = (name: string) => evidence.filter(proof => sameName(proof.scenario, name))
1060 const all = rules.subjects.flatMap(subject => subject.scenarios)
1061 const done = all.filter(one => proofsOf(one.name).length > 0).length
1062 const waiting = all.length - done
1063 const toggle = (name: string) =>
1064 void $.state.set(COLLAPSED, collapsed.includes(name) ? collapsed.filter(one => one !== name) : [...collapsed, name])
1065 const summary = `${done} sur ${all.length} prouvés · ${waiting === 0 ? 'tout est prouvé' : `${waiting} à prouver`}${ticket.id !== '' ? ` · [${md(labelOf(ticket.id))}](${ticket.url})` : ''}`
1066
1067 return (
1068 <Box flexDirection="column" gap={1}>
1069 {all.length === 0 && <Text dimColor>Aucune règle validée pour cette branche.</Text>}
1070 {all.length > 0 && (
1071 <Box flexDirection="row" gap={2} alignItems="center">
1072 {Svg !== undefined && <Svg source={ring(done, all.length)} alt={`${done} scénarios prouvés sur ${all.length}`} width={40} height={40} />}
1073 <Box flexDirection="column" flexGrow={1}>
1074 <Text weight="semibold">{rules.feature || 'Règles'}</Text>
1075 {Markdown !== undefined ? <Markdown text={summary} dimColor /> : <Text size="footnote" dimColor>{summary}</Text>}
1076 </Box>
1077 </Box>
1078 )}
1079 {rules.subjects.map(subject => {
1080 const name = subject.name || 'Scénarios'
1081 const isOpen = !collapsed.includes(name)
1082 const proven = subject.scenarios.filter(one => proofsOf(one.name).length > 0).length
1083 const tone = proven === subject.scenarios.length ? THEME.done : proven > 0 ? THEME.partial : THEME.muted
1084 return (
1085 <Box flexDirection="column" gap={1} marginTop={1}>
1086 <Button key={`subject-${name}`} plain onPress={() => toggle(name)}>
1087 <Text dimColor>{isOpen ? '▾ ' : '▸ '}</Text>
1088 <Text weight="semibold">{name}</Text>
1089 <Text color={tone}>{` ${proven}/${subject.scenarios.length}`}</Text>
1090 </Button>
1091 {isOpen &&
1092 subject.scenarios.map(scenario => {
1093 const proofs = proofsOf(scenario.name)
1094 const text = scenarioMarkdown(scenario.name, scenario.steps, proofs)
1095 return (
1096 <Box flexDirection="column" gap={1} paddingLeft={2}>
1097 {Markdown !== undefined ? <Markdown text={text} /> : <Text>{text}</Text>}
1098 {proofs.length > 0 && (
1099 <Box flexDirection="row" flexWrap="wrap" gap={2} paddingLeft={2}>
1100 {Markdown !== undefined ? (
1101 <Markdown key={`proofs-${scenario.name}`} text={proofs.map(proof => `[${md(proof.subtitle || GROUP_OF[proof.kind])}](${proofLink(proof)})`).join(' ')} />
1102 ) : (
1103 proofs.map(proof => (
1104 <Button key={`proof-${proof.id}`} variant="secondary" onPress={() => void openFile($, proof.file)}>
1105 <Text color={THEME.done}>{`${GLYPH[proof.media]} ${proof.subtitle || GROUP_OF[proof.kind]}`}</Text>
1106 </Button>
1107 ))
1108 )}
1109 </Box>
1110 )}
1111 </Box>
1112 )
1113 })}
1114 </Box>
1115 )
1116 })}
1117 </Box>
1118 )
1119 })
1120
1121 on('ui.render', { component: 'Pane', requestId: PROOFS }, async ($, e) => {
1122 const table = $.ui.resolve(e)
1123 const { Box, Button, Code, Text } = table
1124 const Svg = 'Svg' in table ? table.Svg : undefined
1125 const Markdown = 'Markdown' in table ? table.Markdown : undefined
1126 const list = (await $.state.get(EVIDENCE)).value ?? []
1127 const ticket = (await $.state.get(TICKET)).value ?? NO_TICKET
1128 const visuals = list.filter(item => item.media === 'image' || item.media === 'video')
1129 const texts = list.filter(item => item.media === 'text' || item.media === 'pdf' || (item.media === '' && item.kind !== 'artifact'))
1130 const artifacts = list.filter(item => item.kind === 'artifact')
1131 const tileWidth = Math.max(110, Math.min(170, Math.floor((e.props.bodyColumns * 7.6 - 24) / 2)))
1132 const tileHeight = Math.round(tileWidth * 1.15)
1133 const tiles = new Map<string, string>()
1134 for (const item of visuals) {
1135 const data = Svg !== undefined ? await base64($, item.thumb) : ''
1136 if (data !== '') tiles.set(item.id, cropped(item, data, tileWidth, tileHeight))
1137 }
1138 const files = list.length - artifacts.length
1139 const excerpt = (item: Evidence) => item.detail.split('\n').filter(line => !line.startsWith('#')).join('\n').trim().split('\n').slice(0, 6).join('\n')
1140 const markdown = (text: string, dim = false) => (Markdown !== undefined ? <Markdown text={text} dimColor={dim} /> : <Text dimColor={dim}>{text}</Text>)
1141 const heading = (title: string, count: number) => markdown(`#### ${md(title)} · ${count}`)
1142 const criteria = parseRules((await $.state.get(CRITERIA)).value ?? '')
1143 const stepsOf = (name: string) => criteria.subjects.flatMap(subject => subject.scenarios).find(one => sameName(one.name, name))?.steps ?? []
1144 const subjectOf = (name: string) => criteria.subjects.find(subject => subject.scenarios.some(one => sameName(one.name, name)))?.name ?? 'Règle'
1145 const ruleBadge = (item: Evidence) => {
1146 if (item.scenario === '') return undefined
1147 const chipTone = BLUE_CHIP
1148 const link = ticket.url !== '' ? ticket.url : ''
1149 return (
1150 <Box key={`goto-rule-${item.id}`} position="relative" flexDirection="row" alignItems="center" paddingX={1} backgroundColor={chipTone.fill} borderStyle="round" borderColor={chipTone.fill} hover={{ backgroundColor: chipTone.hover, borderColor: chipTone.hover }}>
1151 {Markdown !== undefined && link !== '' ? <Markdown text={`[§ ${md(item.scenario)}](${link})`} /> : <Text color="suggestion">{`§ ${item.scenario}`}</Text>}
1152 <Box position="absolute" top={2} left={0} display="none" hover={{ display: 'flex' }} flexDirection="column" paddingX={1} borderStyle="round" borderColor="#3A3936" backgroundColor="#1F1E1D" width={Math.max(220, Math.min(320, e.props.bodyColumns * 7))}>
1153 <Text size="footnote" color="#A5A29A">{`Règle · ${subjectOf(item.scenario)}`}</Text>
1154 {stepsOf(item.scenario).map(step => (
1155 <Text color="#ECEAE3">{step}</Text>
1156 ))}
1157 </Box>
1158 </Box>
1159 )
1160 }
1161 const proofBadge = (item: Evidence) =>
1162 Markdown !== undefined ? (
1163 <Markdown key={`open-${item.id}`} text={`[${md(item.subtitle || item.title || GROUP_OF[item.kind])}](${proofLink(item)})`} />
1164 ) : (
1165 <Button key={`open-${item.id}`} variant="secondary" onPress={() => void openFile($, item.file)}>
1166 <Text color={THEME.done}>{`${GLYPH[item.media]} ${item.subtitle || item.title || GROUP_OF[item.kind]}`}</Text>
1167 </Button>
1168 )
1169 const header = `${files} fichier${files > 1 ? 's' : ''}${artifacts.length > 0 ? ` · ${artifacts.length} artefact${artifacts.length > 1 ? 's' : ''}` : ''}${ticket.id !== '' ? ` · [${md(labelOf(ticket.id))}](${ticket.url})` : ''}`
1170
1171 return (
1172 <Box flexDirection="column" gap={1}>
1173 {markdown(header, true)}
1174 {list.length === 0 && <Text dimColor>Aucune preuve pour cette branche.</Text>}
1175 {grouped(visuals).map(([title, items]) => (
1176 <Box flexDirection="column" gap={1}>
1177 {heading(title, items.length)}
1178 <Box flexDirection="row" flexWrap="wrap" gap={2}>
1179 {items.map(item => (
1180 <Box key={`tile-${item.id}`} flexDirection="column" gap={0} width={tileWidth}>
1181 {Svg !== undefined && tiles.has(item.id) && <Svg source={tiles.get(item.id) ?? ''} alt={item.subtitle || item.title} width={tileWidth} height={tileHeight} />}
1182 <Box flexDirection="row" flexWrap="wrap" gap={1} marginTop={1}>
1183 {proofBadge(item)}
1184 {ruleBadge(item)}
1185 </Box>
1186 </Box>
1187 ))}
1188 </Box>
1189 </Box>
1190 ))}
1191 {texts.length > 0 && heading('Requêtes et tests', texts.length)}
1192 {texts.map(item => (
1193 <Box key={`text-${item.id}`} flexDirection="column" gap={0}>
1194 {excerpt(item) !== '' && <Code source={excerpt(item)} language={item.kind === 'curl' ? 'http' : 'bash'} path={item.file.split('/').pop() ?? ''} />}
1195 <Box flexDirection="row" flexWrap="wrap" gap={1} marginTop={1}>
1196 {proofBadge(item)}
1197 {ruleBadge(item)}
1198 </Box>
1199 </Box>
1200 ))}hooks/pages.ts 235 lines1export type PageProof = {
2 id: string
3 kind: string
4 media: string
5 group: string
6 subtitle: string
7 scenario: string
8 src: string
9 text: string
10 link: string
11}
12
13export type PageScenario = { name: string; steps: string[]; proofs: { id: string; label: string }[] }
14
15export type PageSubject = { name: string; scenarios: PageScenario[] }
16
17export type PageTicket = {
18 id: string
19 tracker: string
20 url: string
21 summary: string
22 context: string
23 branch: string
24 branchUrl: string
25 status: string
26 done: number
27 total: number
28 proofs: number
29}
30
31export type PageLinks = { ticket: string; rules: string; proofs: string }
32
33const FONT = `-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif`
34const MONO = `ui-monospace, 'SF Mono', Menlo, monospace`
35
36const LIGHT = '--bg:#FAF9F5;--surface:#FFFFFF;--sunk:#F2F0E9;--line:#E5E2D9;--ink:#141413;--muted:#5E5D59;--faint:#7A7974;--accent:#C2603E;--accent-soft:#F6E5DD;--blue:#2E62B0;--blue-soft:#E3ECF8;--green:#2F7D3B;--green-soft:#E3F1E4;--amber:#8F5A07;--amber-soft:#F8ECD5;--violet:#6A47C2;--violet-soft:#ECE6F8;--shadow:0 1px 2px rgba(20,20,19,.06)'
37const DARK = '--bg:#1C1B19;--surface:#252421;--sunk:#151413;--line:#363431;--ink:#ECEAE3;--muted:#B5B2A9;--faint:#97948C;--accent:#E08A69;--accent-soft:#3A2A23;--blue:#8DB4EC;--blue-soft:#1F2C3E;--green:#7DC481;--green-soft:#1F3323;--amber:#E3B04B;--amber-soft:#3A2F18;--violet:#B9A2F0;--violet-soft:#2C2540;--shadow:none'
38
39const CSS = `
40:root{${LIGHT};color-scheme:light}
41@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){${DARK};color-scheme:dark}}
42:root[data-theme="dark"]{${DARK};color-scheme:dark}
43*{box-sizing:border-box}
44body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.55 ${FONT};-webkit-font-smoothing:antialiased}
45a{color:var(--blue);text-decoration:none}a:hover{text-decoration:underline}
46main{max-width:920px;margin:0 auto;padding:40px 16px 64px;display:flex;flex-direction:column;gap:28px}
47header{display:flex;flex-direction:column;gap:10px}
48.eyebrow{display:flex;flex-wrap:wrap;align-items:center;gap:8px;font-size:13px;color:var(--faint)}
49h1{margin:0;font-size:28px;line-height:1.25;font-weight:600;letter-spacing:-.01em}
50h2{margin:0;font-size:15px;font-weight:600;display:flex;align-items:center;gap:10px}
51h2 .count{font-weight:400;color:var(--faint);font-size:13px}
52.lede{margin:0;color:var(--muted);max-width:68ch}
53.chip{display:inline-flex;align-items:center;gap:6px;height:24px;padding:0 9px;border-radius:7px;font-size:12.5px;font-weight:600;white-space:nowrap;background:var(--blue-soft);color:var(--blue)}
54.chip svg{width:13px;height:13px;flex-shrink:0}
55.chip{max-width:100%;overflow:hidden;text-overflow:ellipsis}.chip .label{overflow:hidden;text-overflow:ellipsis}
56.chip.green{background:var(--green-soft);color:var(--green)}.chip.amber{background:var(--amber-soft);color:var(--amber)}.chip.violet{background:var(--violet-soft);color:var(--violet)}.chip.clay{background:var(--accent-soft);color:var(--accent)}
57.chip:hover{text-decoration:none;filter:brightness(1.06)}
58.row{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
59.card{background:var(--surface);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}
60.progress{display:flex;align-items:center;gap:12px;font-size:13px;color:var(--muted)}
61.bar{flex:1;max-width:240px;height:6px;border-radius:3px;background:var(--sunk);overflow:hidden}.bar span{display:block;height:100%;background:var(--accent);border-radius:3px}
62section{display:flex;flex-direction:column;gap:12px}
63.scenario{padding:16px 18px;display:grid;grid-template-columns:22px minmax(0,1fr);gap:4px 12px}
64.scenario.pending{background:transparent;border-style:dashed}
65.check{width:20px;height:20px;border-radius:10px;display:flex;align-items:center;justify-content:center;margin-top:1px}
66.check.done{background:var(--green);color:var(--surface)}.check.todo{border:1.5px dashed var(--faint)}
67.scenario h3{margin:0;font-size:15px;font-weight:600;line-height:1.4}
68.steps{grid-column:2;margin:2px 0 0;padding:0;list-style:none;font-size:14px;color:var(--muted)}
69.steps b{font-weight:600;color:var(--accent);margin-right:6px}
70.links{grid-column:2;margin-top:8px}
71.waiting{grid-column:2;margin-top:6px;font-size:13px;color:var(--amber)}
72.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px}
73.tile{overflow:hidden;display:flex;flex-direction:column}
74.tile .shot{position:relative;display:block;aspect-ratio:4/5;background:var(--sunk);overflow:hidden}
75.tile .shot img,.tile .shot video{width:100%;height:100%;object-fit:cover;object-position:top;display:block;transition:transform .25s ease}
76.tile .shot:hover img,.tile .shot:hover video{transform:scale(1.03)}
77.zoom{position:absolute;right:10px;top:10px;width:30px;height:30px;border-radius:15px;background:rgba(20,20,19,.72);color:#fff;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .2s}
78.tile .shot:hover .zoom{opacity:1}
79.play{position:absolute;left:50%;top:50%;width:46px;height:46px;margin:-23px 0 0 -23px;border-radius:23px;background:rgba(20,20,19,.7);display:flex;align-items:center;justify-content:center}
80.tile .meta{padding:12px 14px;display:flex;flex-direction:column;gap:8px;min-width:0}
81.tile .meta .row{flex-wrap:nowrap;min-width:0}
82.tile .meta strong{font-size:14px;font-weight:600;line-height:1.35}
83.code{overflow:hidden}
84.code .head{display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid var(--line);font-size:13px}
85.code .head .file{font-family:${MONO};font-size:12px;color:var(--faint)}
86.code pre{margin:0;padding:14px;background:var(--sunk);font:12.5px/1.6 ${MONO};overflow:auto;max-height:340px;white-space:pre}
87.code .foot{padding:10px 14px;border-top:1px solid var(--line)}
88.facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}
89.fact{padding:14px 16px;display:flex;flex-direction:column;gap:6px}
90.fact span{font-size:12px;color:var(--faint)}
91.fact strong{font-size:15px;font-weight:600}
92.context{padding:18px 20px;white-space:pre-wrap;color:var(--muted);font-size:14.5px}
93.lightbox{position:fixed;inset:0;z-index:20;display:none;background:rgba(8,8,8,.86);color:#F2F0EA}
94.lightbox:target{display:flex;flex-direction:column}
95.lightbox .stage{flex:1;display:flex;align-items:center;justify-content:center;padding:56px 16px 16px;min-height:0}
96.lightbox img,.lightbox video{max-width:100%;max-height:100%;border-radius:10px;box-shadow:0 30px 80px rgba(0,0,0,.5)}
97.lightbox .close{position:absolute;right:20px;top:14px;font-size:22px;color:#F2F0EA;width:36px;height:36px;display:flex;align-items:center;justify-content:center;border-radius:18px}
98.lightbox .close:hover{background:rgba(255,255,255,.08);text-decoration:none}
99.lightbox .bar2{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:12px 24px 22px;font-size:14px}
100.lightbox .nav{display:flex;align-items:center;gap:18px}
101.lightbox .nav a{color:#F2F0EA;font-size:22px;width:32px;text-align:center}
102.lightbox .nav a:hover{text-decoration:none;opacity:.8}
103.empty{color:var(--faint);font-size:14px}
104@media (max-width:560px){h1{font-size:23px}main{padding-top:28px}}
105`
106
107const ICONS: Record<string, string> = {
108 ticket: '<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v14"/>',
109 rule: '<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>',
110 image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>',
111 video: '<rect x="2" y="5" width="14" height="14" rx="2"/><path d="m16 10 6-3v10l-6-3"/>',
112 text: '<path d="m4 17 6-6-6-6"/><path d="M12 19h8"/>',
113 artifact: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="m10 13-2 2 2 2"/><path d="m14 17 2-2-2-2"/>',
114 branch: '<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
115 external: '<path d="M7 17 17 7"/><path d="M7 7h10v10"/>',
116}
117
118export function esc(text: string): string {
119 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
120}
121
122function icon(name: string): string {
123 return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name] ?? ''}</svg>`
124}
125
126function chip(label: string, href: string, iconName: string, tone = ''): string {
127 return `<a class="chip ${tone}" href="${esc(href)}" title="${esc(label)}">${icon(iconName)}<span class="label">${esc(label)}</span></a>`
128}
129
130export function slugId(text: string): string {
131 return text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'x'
132}
133
134function page(title: string, body: string): string {
135 return `<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(title)}</title><style>${CSS}</style></head><body><main>${body}</main></body></html>`
136}
137
138function progress(done: number, total: number): string {
139 const ratio = total === 0 ? 0 : Math.round((100 * done) / total)
140
141 return `<div class="progress"><div class="bar"><span style="width:${ratio}%"></span></div>${done} sur ${total} scénario${total > 1 ? 's' : ''} prouvé${done > 1 ? 's' : ''}</div>`
142}
143
144function nav(ticket: PageTicket, links: PageLinks, current: string): string {
145 const items = [
146 current !== 'ticket' && links.ticket !== '' ? chip(`${ticket.id} — Ticket`, links.ticket, 'ticket') : '',
147 current !== 'rules' && links.rules !== '' ? chip(`${ticket.id} — Règles`, links.rules, 'rule') : '',
148 current !== 'proofs' && links.proofs !== '' ? chip(`${ticket.id} — Preuves`, links.proofs, 'image') : '',
149 chip(ticket.tracker, ticket.url, 'external'),
150 ]
151
152 return `<div class="row">${items.filter(Boolean).join('')}</div>`
153}
154
155const STATUS: Record<string, [string, string]> = {
156 open: ['PR ouverte', 'green'],
157 draft: ['PR brouillon', ''],
158 merged: ['PR fusionnée', 'violet'],
159 closed: ['PR fermée', 'clay'],
160 dirty: ['Modifications en cours', 'amber'],
161 clean: ['À jour', ''],
162 none: ['Sans branche', ''],
163}
164
165export function ticketPage(ticket: PageTicket, links: PageLinks): string {
166 const [statusLabel, statusTone] = STATUS[ticket.status] ?? ['', '']
167 const body = `<header><div class="eyebrow">${chip(ticket.id, ticket.url, 'ticket')}<span>Ticket</span></div><h1>${esc(ticket.summary || ticket.id)}</h1>${progress(ticket.done, ticket.total)}${nav(ticket, links, 'ticket')}</header>
168<div class="facts"><div class="card fact"><span>Branche</span><strong>${ticket.branchUrl !== '' ? `<a href="${esc(ticket.branchUrl)}">${esc(ticket.branch)}</a>` : esc(ticket.branch)}</strong>${statusLabel !== '' ? `<div>${chip(statusLabel, ticket.branchUrl || ticket.url, 'branch', statusTone)}</div>` : ''}</div><div class="card fact"><span>Règles</span><strong>${ticket.done} / ${ticket.total} prouvées</strong></div><div class="card fact"><span>Preuves</span><strong>${ticket.proofs}</strong></div></div>
169${ticket.context.trim() !== '' ? `<section><h2>Contexte</h2><div class="card context">${esc(ticket.context.trim())}</div></section>` : ''}`
170
171 return page(`${ticket.id} — Ticket`, body)
172}
173
174export function rulesPage(ticket: PageTicket, feature: string, subjects: PageSubject[], links: PageLinks): string {
175 const sections = subjects
176 .map(subject => {
177 const proven = subject.scenarios.filter(one => one.proofs.length > 0).length
178 const cards = subject.scenarios
179 .map(scenario => {
180 const isDone = scenario.proofs.length > 0
181 const steps = scenario.steps
182 .map(step => {
183 const keyword = step.match(/^\S+/)?.[0] ?? ''
184 return `<li><b>${esc(keyword)}</b>${esc(step.slice(keyword.length).trim())}</li>`
185 })
186 .join('')
187 const proofs = scenario.proofs.map(proof => chip(proof.label, `${links.proofs}#proof-${esc(proof.id)}`, 'image', 'green')).join('')
188 return `<article class="card scenario${isDone ? '' : ' pending'}" id="rule-${slugId(scenario.name)}"><span class="check ${isDone ? 'done' : 'todo'}">${isDone ? '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>' : ''}</span><h3>${esc(scenario.name)}</h3>${steps !== '' ? `<ul class="steps">${steps}</ul>` : ''}${proofs !== '' ? `<div class="row links">${proofs}</div>` : '<div class="waiting">En attente de preuve</div>'}</article>`
189 })
190 .join('')
191 return `<section><h2>${esc(subject.name)}<span class="count">${proven}/${subject.scenarios.length}</span></h2>${cards}</section>`
192 })
193 .join('')
194 const body = `<header><div class="eyebrow">${chip(ticket.id, ticket.url, 'ticket')}<span>Règles d’acceptation</span></div><h1>${esc(feature || 'Règles')}</h1>${progress(ticket.done, ticket.total)}${nav(ticket, links, 'rules')}</header>${sections || '<p class="empty">Aucune règle validée pour ce ticket.</p>'}`
195
196 return page(`${ticket.id} — Règles`, body)
197}
198
199function ruleChip(proof: PageProof, links: PageLinks): string {
200 return proof.scenario !== '' && links.rules !== '' ? chip(proof.scenario, `${links.rules}#rule-${slugId(proof.scenario)}`, 'rule') : ''
201}
202
203export function proofsPage(ticket: PageTicket, proofs: PageProof[], links: PageLinks): string {
204 const visuals = proofs.filter(proof => proof.media === 'image' || proof.media === 'video')
205 const texts = proofs.filter(proof => proof.media === 'text')
206 const artifacts = proofs.filter(proof => proof.kind === 'artifact')
207 const groups = new Map<string, PageProof[]>()
208 for (const proof of visuals) groups.set(proof.group, [...(groups.get(proof.group) ?? []), proof])
209 const viewer = (proof: PageProof, at: number) => {
210 const previous = visuals[(at - 1 + visuals.length) % visuals.length]
211 const next = visuals[(at + 1) % visuals.length]
212 const media = proof.media === 'video' ? `<video src="${esc(proof.src)}" controls playsinline></video>` : `<img src="${esc(proof.src)}" alt="${esc(proof.subtitle)}">`
213 return `<div class="lightbox" id="view-${esc(proof.id)}" role="dialog" aria-label="${esc(proof.subtitle)}"><a class="close" href="#proof-${esc(proof.id)}" aria-label="Fermer">✕</a><div class="stage">${media}</div><div class="bar2"><span>${esc(proof.group)} · ${esc(proof.subtitle)}</span><span class="nav"><a href="#view-${esc(previous?.id ?? proof.id)}" aria-label="Précédente">‹</a>${at + 1} sur ${visuals.length}<a href="#view-${esc(next?.id ?? proof.id)}" aria-label="Suivante">›</a></span><span></span></div></div>`
214 }
215 const tiles = [...groups.entries()]
216 .map(([group, items]) => {
217 const cards = items
218 .map(proof => {
219 const media =
220 proof.media === 'video'
221 ? `<video src="${esc(proof.src)}" muted preload="metadata"></video><span class="play"><svg width="16" height="16" viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z"/></svg></span>`
222 : `<img src="${esc(proof.src)}" alt="${esc(proof.subtitle)}" loading="lazy">`
223 return `<article class="card tile" id="proof-${esc(proof.id)}"><a class="shot" href="#view-${esc(proof.id)}" aria-label="Agrandir ${esc(proof.subtitle)}">${media}<span class="zoom"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/><path d="M11 8v6M8 11h6"/></svg></span></a><div class="meta"><strong>${esc(proof.subtitle)}</strong><div class="row">${ruleChip(proof, links)}</div></div></article>`
224 })
225 .join('')
226 return `<section><h2>${esc(group)}<span class="count">${items.length}</span></h2><div class="grid">${cards}</div></section>`
227 })
228 .join('')
229 const codes = texts.length === 0 ? '' : `<section><h2>Requêtes et tests<span class="count">${texts.length}</span></h2>${texts.map(proof => `<article class="card code" id="proof-${esc(proof.id)}"><div class="head">${chip(proof.kind === 'curl' ? 'cURL' : proof.kind, `#proof-${esc(proof.id)}`, 'text', proof.kind === 'test' ? 'green' : 'clay')}<strong>${esc(proof.subtitle)}</strong><span class="file">${esc(proof.src)}</span></div><pre>${esc(proof.text)}</pre>${ruleChip(proof, links) !== '' ? `<div class="foot row">${ruleChip(proof, links)}</div>` : ''}</article>`).join('')}</section>`
230 const others = artifacts.length === 0 ? '' : `<section><h2>Artefacts<span class="count">${artifacts.length}</span></h2>${artifacts.map(proof => `<div class="row" id="proof-${esc(proof.id)}">${chip(proof.subtitle, proof.link, 'artifact')}${ruleChip(proof, links)}</div>`).join('')}</section>`
231 const body = `<header><div class="eyebrow">${chip(ticket.id, ticket.url, 'ticket')}<span>Preuves</span></div><h1>${esc(ticket.summary || ticket.id)}</h1><p class="lede">${proofs.length} preuve${proofs.length > 1 ? 's' : ''} · ${ticket.done} sur ${ticket.total} scénarios prouvés</p>${nav(ticket, links, 'proofs')}</header>${tiles}${codes}${others}${proofs.length === 0 ? '<p class="empty">Aucune preuve pour ce ticket.</p>' : ''}${visuals.map(viewer).join('')}`
232
233 return page(`${ticket.id} — Preuves`, body)
234}
235hooks/tracker.ts 378 lines1export type TrackerKind = 'youtrack' | 'azure-devops'
2
3export type TrackerConfig = {
4 kind: TrackerKind
5 url: string
6 project: string
7 workItemType: string
8 projects: string[]
9 token: string
10}
11
12export type HttpRequest = { method: string; url: string; headers: Record<string, string>; body?: string }
13
14export type Issue = { summary: string; description: string; rules: string; context: string; comments: string[]; attachments: { name: string; url: string }[] }
15
16const YOUTRACK_ID = /\b[A-Z][A-Z0-9]+-\d+\b/
17const AZURE_ID = /(?:^|\/)(\d+)(?=$|[-_/.])/
18const RULES_HEADING = "Règles d'acceptation"
19const MARKDOWN_RULES = /## Règles d'acceptation\s*```gherkin\n([\s\S]*?)```/
20const HTML_RULES = /<h2>\s*Règles d(?:'|'|’)acceptation\s*<\/h2>\s*<pre[^>]*>([\s\S]*?)<\/pre>/i
21const PROOF_MARK = /acceptance-proof:([A-Za-z0-9_-]+)/
22const LEGACY_MARK = /<!-- acceptance-proof:(\{[\s\S]*?\}) -->/
23
24function text(value: unknown): string {
25 return typeof value === 'string' ? value.trim() : ''
26}
27
28const TRACKER_NAMES: Record<TrackerKind, string> = { youtrack: 'YouTrack', 'azure-devops': 'Azure DevOps' }
29const SAFE_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
30const SAFE_ID = /^[A-Za-z0-9_-]{1,64}$/
31
32export function trackerName(config: TrackerConfig): string {
33 return TRACKER_NAMES[config.kind]
34}
35
36export function safeFileName(name: string): string {
37 return SAFE_FILE.test(name) && !name.includes('..') ? name : ''
38}
39
40export function isProofId(id: unknown): id is string {
41 return typeof id === 'string' && SAFE_ID.test(id)
42}
43
44export function isTrackerUrl(config: TrackerConfig, url: string): boolean {
45 try {
46 const target = new URL(url)
47 const home = new URL(config.url)
48 return target.protocol === 'https:' && target.origin === home.origin
49 } catch {
50 return false
51 }
52}
53
54export function curlString(value: string): string | undefined {
55 if (/[\u0000-\u001f\u007f]/.test(value)) return undefined
56 return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
57}
58
59export function curlLines(entries: [string, string][]): string[] {
60 const lines = entries.map(([key, value]) => {
61 const quoted = curlString(value)
62 return quoted === undefined ? undefined : `${key} = ${quoted}`
63 })
64 return lines.every(line => line !== undefined) ? (lines as string[]) : []
65}
66
67export function configOf(options: Readonly<Record<string, unknown>>, envToken: string): TrackerConfig {
68 const kind: TrackerKind = options.tracker === 'azure-devops' ? 'azure-devops' : 'youtrack'
69
70 return {
71 kind,
72 url: text(options.trackerUrl).replace(/\/+$/, ''),
73 project: text(options.azureProject),
74 workItemType: text(options.azureWorkItemType) || 'User Story',
75 projects: text(options.projects)
76 .split(',')
77 .map(one => one.trim())
78 .filter(Boolean),
79 token: text(options.token) || envToken.trim(),
80 }
81}
82
83export const SETTINGS_FILE = '.claude/acceptance.json'
84
85export type TrackerSettings = { tracker: TrackerKind; trackerUrl: string; projects: string; azureProject: string; azureWorkItemType: string }
86
87export function normalizedUrl(raw: string): string {
88 if (/[\\\s@]/.test(raw)) return ''
89 try {
90 const parsed = new URL(raw)
91 if (parsed.protocol !== 'https:' || parsed.username !== '' || parsed.password !== '' || parsed.search !== '' || parsed.hash !== '') return ''
92 return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '')
93 } catch {
94 return ''
95 }
96}
97
98export function settingsOf(args: string): TrackerSettings | string {
99 const [url = '', target = '', ...rest] = args.trim().split(/\s+/).filter(Boolean)
100 const trackerUrl = normalizedUrl(url)
101 if (trackerUrl === '') return "L'URL du tracker doit être une adresse https simple, sans identifiants, requête ni fragment (ex. https://<org>.youtrack.cloud)."
102 const azure = /(^|\.)(dev\.azure\.com|visualstudio\.com)$/i.test(new URL(trackerUrl).hostname)
103 if (azure && target === '') return 'Azure DevOps : précise le projet après l\'URL.'
104
105 return azure
106 ? { tracker: 'azure-devops', trackerUrl, projects: '', azureProject: target, azureWorkItemType: rest.join(' ') || 'User Story' }
107 : { tracker: 'youtrack', trackerUrl, projects: target, azureProject: '', azureWorkItemType: '' }
108}
109
110export function isReady(config: TrackerConfig): boolean {
111 return config.url !== '' && config.token !== '' && (config.kind === 'youtrack' || config.project !== '')
112}
113
114export function ticketOf(config: TrackerConfig, branch: string): string {
115 if (config.kind === 'azure-devops') return branch.match(AZURE_ID)?.[1] ?? ''
116 return branch.match(YOUTRACK_ID)?.[0] ?? ''
117}
118
119export function isTicketId(config: TrackerConfig, id: string): boolean {
120 return config.kind === 'azure-devops' ? /^\d+$/.test(id) : /^[A-Z][A-Z0-9]+-\d+$/.test(id)
121}
122
123export function displayId(config: TrackerConfig, id: string): string {
124 return config.kind === 'azure-devops' ? labelOf(id) : id
125}
126
127export function labelOf(id: string): string {
128 return /^\d+$/.test(id) ? `#${id}` : id
129}
130
131export function issueUrl(config: TrackerConfig, id: string): string {
132 if (id === '' || config.url === '') return ''
133 if (config.kind === 'azure-devops') return `${config.url}/${encodeURIComponent(config.project)}/_workitems/edit/${id}`
134 return `${config.url}/issue/${id}`
135}
136
137export function exampleId(config: TrackerConfig): string {
138 return config.kind === 'azure-devops' ? '1234' : `${config.projects[0] ?? 'PROJ'}-42`
139}
140
141function base64(textValue: string): string {
142 const bytes = new TextEncoder().encode(textValue)
143 let binary = ''
144 for (const byte of bytes) binary += String.fromCharCode(byte)
145 return btoa(binary)
146}
147
148function fromBase64(value: string): string {
149 const binary = atob(value)
150 const bytes = new Uint8Array(binary.length)
151 for (let at = 0; at < binary.length; at += 1) bytes[at] = binary.charCodeAt(at)
152 return new TextDecoder().decode(bytes)
153}
154
155export function authHeader(config: TrackerConfig): string {
156 return config.kind === 'azure-devops' ? `Basic ${base64(`:${config.token}`)}` : `Bearer ${config.token}`
157}
158
159function azureApi(config: TrackerConfig, path: string): string {
160 return `${config.url}/${encodeURIComponent(config.project)}/_apis/wit/${path}`
161}
162
163function json(config: TrackerConfig, method: string, url: string, body?: unknown, contentType = 'application/json'): HttpRequest {
164 return {
165 method,
166 url,
167 headers: { Authorization: authHeader(config), Accept: 'application/json', 'Content-Type': contentType },
168 body: body === undefined ? undefined : JSON.stringify(body),
169 }
170}
171
172export function escapeHtml(value: string): string {
173 return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
174}
175
176function unescapeHtml(value: string): string {
177 return value
178 .replace(/</g, '<')
179 .replace(/>/g, '>')
180 .replace(/"/g, '"')
181 .replace(/'/g, "'")
182 .replace(/ /g, ' ')
183 .replace(/&/g, '&')
184}
185
186function htmlToText(value: string): string {
187 return unescapeHtml(
188 value
189 .replace(/<br\s*\/?>/gi, '\n')
190 .replace(/<\/(p|div|li|h[1-6])>/gi, '\n')
191 .replace(/<li[^>]*>/gi, '- ')
192 .replace(/<[^>]+>/g, ''),
193 )
194 .replace(/\n{3,}/g, '\n\n')
195 .trim()
196}
197
198function markdownToHtml(markdown: string): string {
199 return markdown
200 .split(/\n{2,}/)
201 .map(block => {
202 const lines = block.split('\n')
203 if (lines.every(line => /^\s*[-*] /.test(line))) return `<ul>${lines.map(line => `<li>${escapeHtml(line.replace(/^\s*[-*] /, ''))}</li>`).join('')}</ul>`
204 const heading = block.match(/^(#{1,4})\s+(.*)$/)
205 if (heading !== null && lines.length === 1) return `<h${(heading[1] ?? '#').length + 1}>${escapeHtml(heading[2] ?? '')}</h${(heading[1] ?? '#').length + 1}>`
206 return `<p>${escapeHtml(block).replace(/\n/g, '<br>')}</p>`
207 })
208 .join('')
209}
210
211export function rulesBlock(config: TrackerConfig, feature: string): string {
212 if (config.kind === 'azure-devops') return `<h2>${RULES_HEADING}</h2><pre>${escapeHtml(feature.trim())}</pre>`
213 return `## ${RULES_HEADING}\n\n\`\`\`gherkin\n${feature.trim()}\n\`\`\``
214}
215
216export function withRules(config: TrackerConfig, description: string, feature: string): string {
217 const pattern = config.kind === 'azure-devops' ? HTML_RULES : MARKDOWN_RULES
218 const block = rulesBlock(config, feature)
219 if (pattern.test(description)) return description.replace(pattern, block)
220
221 return config.kind === 'azure-devops' ? `${description}${block}` : `${description.trim()}\n\n${block}`.trim()
222}
223
224export function descriptionFor(config: TrackerConfig, context: string, feature: string): string {
225 const body = config.kind === 'azure-devops' ? markdownToHtml(context) : context
226
227 return feature.trim() === '' ? body : withRules(config, body, feature)
228}
229
230export function rulesOf(config: TrackerConfig, description: string): string {
231 if (config.kind === 'azure-devops') return unescapeHtml(description.match(HTML_RULES)?.[1] ?? '').trim()
232 return description.match(MARKDOWN_RULES)?.[1]?.trim() ?? ''
233}
234
235export function contextOf(config: TrackerConfig, description: string): string {
236 if (config.kind === 'azure-devops') return htmlToText(description.replace(HTML_RULES, ''))
237 return description.replace(MARKDOWN_RULES, '').trim()
238}
239
240export function proofMarker(value: unknown): string {
241 return `\`acceptance-proof:${base64(JSON.stringify(value)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}\``
242}
243
244export function proofOf(comment: string): unknown {
245 const encoded = comment.match(PROOF_MARK)?.[1]
246 if (encoded !== undefined) {
247 const padded = encoded.replace(/-/g, '+').replace(/_/g, '/')
248 try {
249 return JSON.parse(fromBase64(padded + '='.repeat((4 - (padded.length % 4)) % 4)))
250 } catch {
251 return undefined
252 }
253 }
254 const legacy = comment.match(LEGACY_MARK)?.[1]
255 if (legacy === undefined) return undefined
256 try {
257 return JSON.parse(legacy)
258 } catch {
259 return undefined
260 }
261}
262
263export function projectsRequest(config: TrackerConfig): HttpRequest {
264 return json(config, 'GET', `${config.url}/api/admin/projects?fields=id,shortName`)
265}
266
267export function createRequest(config: TrackerConfig, projectId: string, summary: string, description: string): HttpRequest {
268 if (config.kind === 'azure-devops') {
269 return json(
270 config,
271 'POST',
272 azureApi(config, `workitems/$${encodeURIComponent(config.workItemType)}?api-version=7.1`),
273 [
274 { op: 'add', path: '/fields/System.Title', value: summary },
275 { op: 'add', path: '/fields/System.Description', value: description },
276 ],
277 'application/json-patch+json',
278 )
279 }
280 return json(config, 'POST', `${config.url}/api/issues?fields=idReadable`, { project: { id: projectId }, summary, description })
281}
282
283export function createdIdOf(config: TrackerConfig, response: unknown): string {
284 const value = typeof response === 'object' && response !== null ? (response as Record<string, unknown>) : {}
285 if (config.kind === 'azure-devops') return value.id === undefined ? '' : String(value.id)
286 return text(value.idReadable)
287}
288
289export function issueRequest(config: TrackerConfig, id: string): HttpRequest {
290 if (config.kind === 'azure-devops') return json(config, 'GET', azureApi(config, `workitems/${id}?$expand=relations&api-version=7.1`))
291 return json(config, 'GET', `${config.url}/api/issues/${id}?fields=idReadable,summary,description,comments(text),attachments(name,url)`)
292}
293
294export function commentsRequest(config: TrackerConfig, id: string): HttpRequest | undefined {
295 return config.kind === 'azure-devops' ? json(config, 'GET', azureApi(config, `workItems/${id}/comments?api-version=7.1-preview.4`)) : undefined
296}
297
298export function issueOf(config: TrackerConfig, response: unknown, comments: unknown): Issue {
299 const value = typeof response === 'object' && response !== null ? (response as Record<string, unknown>) : {}
300 if (config.kind === 'azure-devops') {
301 const fields = (value.fields ?? {}) as Record<string, unknown>
302 const description = text(fields['System.Description'])
303 const relations = Array.isArray(value.relations) ? (value.relations as Record<string, unknown>[]) : []
304 const list = typeof comments === 'object' && comments !== null && Array.isArray((comments as { comments?: unknown }).comments) ? ((comments as { comments: Record<string, unknown>[] }).comments) : []
305 return {
306 summary: text(fields['System.Title']),
307 description,
308 rules: rulesOf(config, description),
309 context: contextOf(config, description),
310 comments: list.map(one => unescapeHtml(text(one.text))),
311 attachments: relations
312 .filter(one => one.rel === 'AttachedFile')
313 .map(one => ({ name: text((one.attributes as Record<string, unknown> | undefined)?.name), url: text(one.url) })),
314 }
315 }
316 const description = text(value.description)
317 const commentsList = Array.isArray(value.comments) ? (value.comments as Record<string, unknown>[]) : []
318 const attachments = Array.isArray(value.attachments) ? (value.attachments as Record<string, unknown>[]) : []
319 return {
320 summary: text(value.summary),
321 description,
322 rules: rulesOf(config, description),
323 context: contextOf(config, description),
324 comments: commentsList.map(one => text(one.text)),
325 attachments: attachments.map(one => ({ name: text(one.name), url: text(one.url).startsWith('http') ? text(one.url) : `${config.url}${text(one.url)}` })),
326 }
327}
328
329export function descriptionRequest(config: TrackerConfig, id: string, description: string): HttpRequest {
330 if (config.kind === 'azure-devops') {
331 return json(config, 'PATCH', azureApi(config, `workitems/${id}?api-version=7.1`), [{ op: 'add', path: '/fields/System.Description', value: description }], 'application/json-patch+json')
332 }
333 return json(config, 'POST', `${config.url}/api/issues/${id}?fields=id`, { description })
334}
335
336export function commentRequest(config: TrackerConfig, id: string, markdown: string): HttpRequest {
337 if (config.kind === 'azure-devops') return json(config, 'POST', azureApi(config, `workItems/${id}/comments?format=markdown&api-version=7.1-preview.4`), { text: markdown })
338 return json(config, 'POST', `${config.url}/api/issues/${id}/comments?fields=id`, { text: markdown })
339}
340
341export function uploadLines(config: TrackerConfig, id: string, file: string, name: string): string[] {
342 if (config.kind === 'azure-devops') {
343 return curlLines([
344 ['url', azureApi(config, `attachments?fileName=${encodeURIComponent(name)}&api-version=7.1`)],
345 ['header', 'Content-Type: application/octet-stream'],
346 ['data-binary', `@${file}`],
347 ])
348 }
349 if (/[;,"]/.test(file)) return []
350 return curlLines([
351 ['url', `${config.url}/api/issues/${encodeURIComponent(id)}/attachments?fields=name,url`],
352 ['form', `upload=@${file}`],
353 ])
354}
355
356export function uploadedUrlOf(config: TrackerConfig, output: string): string {
357 try {
358 const parsed: unknown = JSON.parse(output)
359 if (config.kind === 'azure-devops') return text((parsed as Record<string, unknown>).url)
360 const first = Array.isArray(parsed) ? (parsed[0] as Record<string, unknown> | undefined) : undefined
361 const url = text(first?.url)
362 return url === '' ? '' : `${config.url}${url}`
363 } catch {
364 return ''
365 }
366}
367
368export function linkAttachmentRequest(config: TrackerConfig, id: string, url: string, name: string): HttpRequest | undefined {
369 if (config.kind !== 'azure-devops') return undefined
370 return json(
371 config,
372 'PATCH',
373 azureApi(config, `workitems/${id}?api-version=7.1`),
374 [{ op: 'add', path: '/relations/-', value: { rel: 'AttachedFile', url, attributes: { name } } }],
375 'application/json-patch+json',
376 )
377}
378hooks/look.ts 25 lines1export const THEME = {
2 ink: 'text',
3 muted: 'inactive',
4 line: 'subtle',
5 accent: 'claude',
6 done: 'success',
7 partial: 'warning',
8 pending: 'warning',
9} as const
10
11const MID = {
12 clay: '#D97757',
13 green: '#3FA35B',
14 amber: '#C98A1B',
15 gray: '#8A8984',
16 track: 'rgba(138,137,132,0.32)',
17}
18
19export function ring(done: number, total: number): string {
20 const length = 94.2
21 const filled = total === 0 ? 0 : (length * done) / total
22
23 return `<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" viewBox="0 0 36 36"><circle cx="18" cy="18" r="15" fill="none" stroke="${MID.track}" stroke-width="3.5"/><circle cx="18" cy="18" r="15" fill="none" stroke="${MID.clay}" stroke-width="3.5" stroke-linecap="round" stroke-dasharray="${filled.toFixed(1)} ${length}" transform="rotate(-90 18 18)"/></svg>`
24}
25types/index.d.ts 38 lines1export type EvidenceKind = 'capture' | 'video' | 'curl' | 'test' | 'artifact' | 'file' | 'other'
2
3export type EvidenceMedia = 'image' | 'video' | 'text' | 'pdf' | ''
4
5export type Evidence = {
6 id: string
7 kind: EvidenceKind
8 title: string
9 subtitle: string
10 scenario: string
11 detail: string
12 file: string
13 media: EvidenceMedia
14 thumb: string
15 poster: string
16 large: string
17 width: number
18 height: number
19 remote: string
20 page: string
21 isSynced: boolean
22 at: number
23}
24
25export type BranchStatus = 'none' | 'clean' | 'dirty' | 'open' | 'draft' | 'merged' | 'closed'
26
27export type BranchInfo = { name: string; status: BranchStatus; url: string; pr: number }
28
29export type Ticket = { id: string; url: string; summary: string; context: string }
30
31export type Pages = { ticket: string; rules: string; proofs: string }
32
33declare module 'claude-code' {
34 interface PluginState {
35 acceptance: { evidence: Evidence[]; branch: BranchInfo; criteria: string; ticket: Ticket; collapsed: string[]; pages: Pages }
36 }
37}
38