SLOPSHOPPER

Acceptance

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…

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · acceptance
│ ┃ acceptance-rules ✕ › fix the failing auth test and add an audit log call │ ┃ Aucune règle validée pour cette branche. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /acceptance-setup │ ⎿ acceptance: L'URL du tracker doit être une adresse https simple, │ ⎿ acceptance: │ ⎿ acceptance: Usage : /acceptance-setup <url> [projets YouTrack, e │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · acceptance-rules
Aucune règle validée pour cette branche.
Pane · acceptance-proofs
0 fichier Aucune preuve pour cette branche.
README

Acceptance

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.

  • Rules. For any feature or fix, Claude rewrites the request as Gherkin scenarios grouped by subject (Rule:), asks the questions that change them, and waits for your go-ahead. The rules you approve go into the ticket description.
  • Ticket and branch. With no ticket on the branch, Claude creates one with the full context (the request, the why, the decisions you made) and switches to feature/<ID> (fix/<ID> for a bug). Creating a branch without a ticket number is refused.
  • Proofs. cURL calls, test runs, screenshots, screen recordings and artifacts are captured as they happen or added on purpose, each tied to the scenario it proves. Text proofs become .txt files; every file is attached to the ticket with a comment.
  • Three artifacts per ticket. <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.
  • A band above the prompt. 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).
  • Any session picks it up. The branch name points to the ticket; a new session reads the rules and proofs back from it.
  • Final summary. Claude ends the work by reporting each scenario with its proof and listing the three artifacts by name and link.

The criteria, prompts and artifacts are written in French.

Install

Two things are set up separately:

  • The tracker (which YouTrack or Azure DevOps, which projects): once per repository, in a committed file.
  • Your token: once per place you work (your machine, a cloud environment). It never goes into the repository.

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).

1. The tracker — once per repository

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]
ArgumentYouTrackAzure DevOps
URLhttps://<org>.youtrack.cloudhttps://dev.azure.com/<organization> (detected from the host)
ThenShort names tickets may be created in, the default first: CORE,GROWTHThe project that holds the work items, then the work item type (User Story by default)
Ticket IDsCORE-42 → branch feature/CORE-421234 → 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.

2a. Local install — on your machine

Works the same in every local surface: the CLI, Claude Desktop and the IDE extensions.

  1. Install the plugin:
   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.

  1. Add your token, bound to its tracker, to ~/.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.

  1. Restart Claude Code: /exit then claude in the CLI, or quit the app completely (Cmd+Q / Alt+F4) and reopen it.
  1. Check: in a session opened on the repository, ask Claude to resume a ticket (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:

  1. The plugin's 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.
  2. The ACCEPTANCE_TRACKER_TOKEN environment variable. In a cloud environment this is the way, see 2b.
  3. The file ~/.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.

2b. Cloud environment — claude.ai/code

  1. Make the plugin available to cloud sessions. Either it is installed on your account, or the repository declares it in its committed .claude/settings.json:
   {
     "extraKnownMarketplaces": { "badjilounes": { "source": { "source": "github", "repo": "badjilounes/claude-code-plugins" } } },
     "enabledPlugins": { "acceptance@badjilounes": true }
   }
  1. Add the token to the cloud environment: environment menu in the session's title bar → Edit → environment variables. Add it as a plain variable, not under network secrets, and without quotes:
   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/&lt;organization&gt; 3x….

  1. Start a new session: variables are read when a session starts.
  1. Check: ask Claude to resume a ticket (use_ticket CORE-42).

Troubleshooting

The tools say exactly what is missing instead of calling the ticket missing:

MessageFix
n'est pas configuré : URL (/acceptance-setup)No .claude/acceptance.json at the repository root: step 1, then commit it.
n'est pas configuré : jetonNo 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 machineEither 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 / 404The 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.

Why the URL goes with the token

.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:

  • A bound token (<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.

Collaborate

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:

  1. Install the plugin (2a, step 1).
  2. Create their own token: a YouTrack permanent token, or an Azure DevOps personal access token with Work Items (Read & write).
  3. Add it to their own ~/.config/acceptance/token, on the line of the repository's tracker (2a, step 2).
  4. Restart Claude Code. No /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:

TraceabilityEveryone acts under their own identity: tickets created, rules saved and proofs attached appear in the tracker under the name of whoever did the work.
RevocationRevoking one collaborator's token affects nobody else.
SafetyA 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.

Tools Claude gets

ToolWhat it does
mcp__acceptance__save_acceptance_criteriaSaves the approved Gherkin locally and into the ticket description.
mcp__acceptance__create_ticketCreates the ticket with its context and rules, then switches to feature/<ID> or fix/<ID>.
mcp__acceptance__use_ticketResumes an existing ticket on its branch.
mcp__acceptance__add_evidenceAdds 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.

Local files

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).

Develop

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.

Source 5 files
hooks/register.tsx 1214 lines
1import 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![${item.title}](${fileName})` : `\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 lines
1export 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
235
hooks/tracker.ts 378 lines
1export 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(?:'|&#39;|’)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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
174}
175
176function unescapeHtml(value: string): string {
177  return value
178    .replace(/&lt;/g, '<')
179    .replace(/&gt;/g, '>')
180    .replace(/&quot;/g, '"')
181    .replace(/&#39;/g, "'")
182    .replace(/&nbsp;/g, ' ')
183    .replace(/&amp;/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}
378
hooks/look.ts 25 lines
1export 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}
25
types/index.d.ts 38 lines
1export 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