De ticket GitHub à spec technique exploitable avec Claude.ai (JSON Schema + Node.js)
Construisez un petit outil Node.js qui transforme un ticket GitHub en spécification technique JSON validée, grâce aux sorties structurées de Claude.ai. Tutoriel actionnable, du schéma à la PR automatisée.
Sommaire
De ticket GitHub à spec technique exploitable avec Claude.ai (JSON Schema + Node.js)
Ce tutoriel montre comment convertir automatiquement un ticket GitHub en spécification technique JSON validée, prête à être consommée par vos outils de delivery. Nous allons construire un petit outil Node.js, guider Claude 3.5 Sonnet via sorties structurées (JSON Schema), valider le JSON avec AJV, puis pousser le résultat dans le repo et commenter le ticket.
Objectif
L’objectif est de créer une CLI qui, pour un numéro d’issue GitHub, récupère le ticket et ses commentaires, génère une spec technique structurée (résumé, contexte, décisions, contraintes, tâches, critères d’acceptation, risques, estimations), valide la sortie via un JSON Schema, persiste le fichier spec.json dans le repo et peut automatiquement ouvrir une PR ou laisser un commentaire de synthèse dans l’issue.
Par exemple, en lançant la commande pour l’issue #123 d’un repo, vous obtiendrez un fichier specs/spec-123.json avec des champs propres, des tableaux bornés, des estimations chiffrées, éventuellement une branche git et un commentaire dans GitHub contenant un résumé et un diff.
Vue d’ensemble: pipeline « ticket → spec.json »
Le pipeline suit un flux simple et déterministe:
- On récupère le ticket GitHub (titre, description, commentaires) et on extrait de petits extraits de code pertinents du repo pour donner du contexte à Claude, sans envoyer toute la base de code.
- On construit un prompt court et consistant: un system prompt cadré, un user prompt comprenant le ticket nettoyé et le contexte minimal.
- On appelle Claude 3.5 Sonnet en demandant une sortie strictement conforme à un JSON Schema (response_format=json_schema).
- On valide le JSON avec AJV. En cas d’erreurs, on renvoie à Claude les messages de validation pour auto-correction.
- On normalise et écrit specs/spec-
.json, puis on commente l’issue et, si souhaité, on pousse une branche et ouvre une PR.
Ce pipeline fonctionne avec Node.js 18+, un repo GitHub accessible, et une clé API Anthropic.
Pré-requis et mise en place
Commencez par créer une clé API Anthropic dans votre compte et exportez-la dans votre environnement. Ensuite, préparez un projet TypeScript minimal.
Exemple d’initialisation:
# 1) Dossier et dépendances
mkdir gh-specs && cd gh-specs
npm init -y
npm i @anthropic-ai/sdk ajv undici commander dotenv
npm i -D typescript tsx @types/node
# 2) TypeScript strict
npx tsc --init --strict true --rootDir src --outDir dist --module ES2022 --target ES2022 --moduleResolution node
# 3) Arborescence
mkdir -p src/specs src/lib
Ajoutez un .env pour les secrets:
cat > .env << 'EOF'
ANTHROPIC_API_KEY=sk-ant-xxxxx
GITHUB_TOKEN=ghp_xxxxx
EOF
Un package.json minimal avec scripts utiles:
{
"name": "gh-specs",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc -p .",
"start": "node dist/index.js",
"lint": "node -e \"console.log('Add your linter here')\""
}
}
Et un tsconfig pertinent (extrait):
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"rootDir": "src",
"outDir": "dist",
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true
},
"include": ["src"]
}
Chargez les variables d’environnement en début de programme avec dotenv pour développer en local:
// src/env.ts
import 'dotenv/config';
export const env = {
anthropicKey: process.env.ANTHROPIC_API_KEY ?? '',
githubToken: process.env.GITHUB_TOKEN ?? '',
};
if (!env.anthropicKey) {
throw new Error('ANTHROPIC_API_KEY manquant dans .env');
}
Définir un JSON Schema minimal mais robuste pour la spec
Un schéma concis améliore la conformité du modèle et réduit les coûts. Nommons-le TechSpecV1, avec versionnage et des contraintes explicites (types, longueurs, bornes). Voici un exemple qui impose des chaînes non vides, limite la verbosité et introduit des enums pour réduire les hallucinations.
// src/spec-schema.ts
export const TECH_SPEC_SCHEMA_NAME = 'TechSpecV1';
export const TechSpecSchema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
$id: 'https://example.com/schemas/tech-spec-v1.json',
title: 'TechSpec',
description: 'Spécification technique structurée issue d’un ticket GitHub',
type: 'object',
additionalProperties: false,
required: ['schema_version', 'summary', 'contexte', 'taches', 'acceptance_criteria'],
properties: {
schema_version: {
type: 'string',
const: '1.0.0',
description: 'Version de ce schema pour compatibilité'
},
summary: {
type: 'string',
minLength: 10,
maxLength: 600,
description: 'Résumé exécutif de la solution proposée'
},
contexte: {
type: 'string',
minLength: 10,
maxLength: 2000,
description: 'Contexte fonctionnel et technique nécessaire'
},
decisions: {
type: 'array',
maxItems: 6,
description: 'Décisions d’architecture clés',
items: { type: 'string', minLength: 3, maxLength: 200 }
},
contraintes: {
type: 'array',
maxItems: 8,
description: 'Contraintes et invariants (sécurité, perf, compat, etc.)',
items: { type: 'string', minLength: 3, maxLength: 200 }
},
taches: {
type: 'array',
minItems: 1,
maxItems: 15,
description: 'Découpage en tâches techniques actionnables',
items: {
type: 'object',
additionalProperties: false,
required: ['title'],
properties: {
title: { type: 'string', minLength: 3, maxLength: 120 },
priority: { type: 'string', enum: ['low', 'medium', 'high'] },
estimate_hours: { type: 'number', minimum: 0, maximum: 200 },
dependencies: {
type: 'array',
maxItems: 10,
items: { type: 'string', minLength: 1, maxLength: 80 }
}
}
}
},
acceptance_criteria: {
type: 'array',
minItems: 1,
maxItems: 20,
description: 'Critères testables de validation',
items: { type: 'string', minLength: 5, maxLength: 200 }
},
risques: {
type: 'array',
maxItems: 8,
description: 'Risques identifiés',
items: {
type: 'object',
additionalProperties: false,
required: ['description', 'probabilite', 'impact'],
properties: {
description: { type: 'string', minLength: 5, maxLength: 200 },
probabilite: { type: 'string', enum: ['low', 'medium', 'high'] },
impact: { type: 'string', enum: ['low', 'medium', 'high'] },
mitigation: { type: 'string', minLength: 3, maxLength: 200 }
}
}
},
estimations: {
type: 'object',
additionalProperties: false,
description: 'Synthèse des coûts',
properties: {
total_hours: { type: 'number', minimum: 0, maximum: 1000 },
confidence: { type: 'number', minimum: 0, maximum: 1 }
}
},
status: {
type: 'string',
enum: ['ok', 'insufficient_context'],
description: 'Permet de signaler un manque d’information'
},
notes: {
type: 'string',
maxLength: 800,
description: 'Remarques complémentaires, clarifications demandées'
}
}
} as const;
En pratique, ce schéma limite la taille des sections, force des descriptions testables et prévoit un status=insufficient_context utilisable quand le ticket est trop pauvre. Avec ce garde-fou, vous évitez des spécifications inventées et vous obtenez des demandes de clarification exploitables.
Implémenter l’appel Claude avec sorties structurées
L’appel au SDK doit être strict, avec un system prompt concis qui contraint le modèle à ne produire que du JSON valide. On fixe une température faible et on prévoit un mécanisme de retry si la réponse est tronquée.
// src/lib/claude.ts
import Anthropic from '@anthropic-ai/sdk';
import { TECH_SPEC_SCHEMA_NAME, TechSpecSchema } from '../spec-schema.js';
export type SpecJson = unknown; // validée ensuite par AJV
export interface ClaudeOptions {
model?: string;
temperature?: number;
maxOutputTokens?: number;
}
const DEFAULTS: Required<ClaudeOptions> = {
model: 'claude-3-5-sonnet-latest',
temperature: 0.2,
maxOutputTokens: 2000
};
export class ClaudeClient {
private client: Anthropic;
constructor(apiKey: string) {
this.client = new Anthropic({ apiKey });
}
async generateSpec(input: { system: string; user: string }, opts: ClaudeOptions = {}): Promise<{
json: any;
meta: { id: string; usage: { input_tokens: number; output_tokens: number }; stop_reason: string | null };
}> {
const { model, temperature, maxOutputTokens } = { ...DEFAULTS, ...opts };
const res = await this.client.messages.create({
model,
temperature,
max_output_tokens: maxOutputTokens,
system: input.system,
messages: [{ role: 'user', content: input.user }],
response_format: {
type: 'json_schema',
json_schema: {
name: TECH_SPEC_SCHEMA_NAME,
schema: TechSpecSchema,
strict: true
}
}
});
const block = res.content[0];
let json: any;
if ((block as any).type === 'output_json' && (block as any).json) {
json = (block as any).json;
} else if ((block as any).type === 'text' && (block as any).text) {
json = JSON.parse((block as any).text);
} else {
throw new Error(`Réponse inattendue du modèle: ${JSON.stringify(block)}`);
}
return {
json,
meta: { id: res.id, usage: res.usage, stop_reason: res.stop_reason }
};
}
}
Un system prompt efficace tient en deux phrases. Par exemple:
export const SYSTEM_PROMPT = [
'Tu es un assistant technique qui produit UNIQUEMENT un JSON valide conforme au schéma fourni.',
'Style concis, pas d’explications hors JSON. Utilise status=insufficient_context si les infos manquent.'
].join(' ');
Le user prompt assemble le ticket nettoyé et le contexte extrait:
export function buildUserPrompt(payload: {
repo: string;
issueNumber: number;
title: string;
body: string;
comments: string[];
codeSnippets: { path: string; snippet: string }[];
}) {
const comments = payload.comments.slice(0, 10).map((c, i) => `Comment ${i + 1}:\n${c}`).join('\n\n');
const code = payload.codeSnippets
.slice(0, 5)
.map(s => `File: ${s.path}\n---\n${s.snippet}`)
.join('\n\n');
return [
`Repository: ${payload.repo}`,
`Issue #${payload.issueNumber}: ${payload.title}`,
'Ticket:',
payload.body,
comments ? `\nCommentaires:\n${comments}` : '',
code ? `\nExtraits de code pertinents:\n${code}` : ''
].join('\n\n');
}
Si stop_reason vaut max_tokens, vous pouvez relancer en augmentant max_output_tokens (par exemple +1000) et en loggant l’ID et l’usage pour traçabilité:
// src/lib/retry.ts
export async function withTruncationRetry<T>(
run: (maxTokens: number) => Promise<{ value: T; stop_reason: string | null }>,
initMaxTokens = 2000,
maxAttempts = 2
): Promise<T> {
let tokens = initMaxTokens;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const { value, stop_reason } = await run(tokens);
if (stop_reason !== 'max_tokens') return value;
tokens += 1000;
}
throw new Error('Réponse tronquée malgré les retries');
}
Récupérer le contexte: ticket GitHub et snippets de code
Récupérez le ticket et ses commentaires via l’API REST v3. Un token GitHub augmente les limites rate-limit et permet d’accéder aux repos privés. Nettoyez le Markdown pour enlever signatures, images volumineuses et logs bruyants. Un simple filtrage regex élimine les blocs trop longs.
// src/lib/github.ts
import { env } from '../env.js';
const GITHUB_API = 'https://api.github.com';
async function ghFetch(url: string) {
const headers: Record<string, string> = {
'User-Agent': 'gh-specs-bot',
Accept: 'application/vnd.github+json'
};
if (env.githubToken) headers.Authorization = `Bearer ${env.githubToken}`;
const res = await fetch(url, { headers });
if (!res.ok) throw new Error(`GitHub API ${res.status} ${res.statusText}: ${await res.text()}`);
return res.json();
}
export async function fetchIssue(repo: string, issueNumber: number) {
const base = `${GITHUB_API}/repos/${repo}`;
const issue = await ghFetch(`${base}/issues/${issueNumber}`);
const comments = await ghFetch(`${base}/issues/${issueNumber}/comments`);
return {
title: issue.title as string,
body: cleanMarkdown(issue.body ?? ''),
comments: (comments as any[]).map(c => cleanMarkdown(c.body ?? ''))
};
}
export function cleanMarkdown(md: string): string {
let s = md.trim();
s = s.replace(/!\[[^\]]*]\([^)]+\)/g, ''); // images
s = s.replace(/```[\s\S]*?```/g, m => (m.length > 2000 ? '```[omitted long code block]```' : m)); // tronque
s = s.replace(/(?:^|\n)>.*(\n>.*)*/g, ''); // citations mail/signatures
s = s.replace(/\n{3,}/g, '\n\n');
return s.slice(0, 6000);
}
Pour les extraits de code, ciblez uniquement les fichiers plausibles mentionnés dans le ticket (routes, hooks, composants). En local, vous pouvez utiliser ripgrep pour extraire quelques contextes autour des mots-clés, puis injecter un échantillon compact.
Exemple en bash pour préparer 3 extraits max et limiter à ~2–3 kB:
# Exemple: rechercher "checkout" et "payment" dans src/
rg -n --max-columns=160 --no-heading -C 3 -e 'checkout|payment' src \
| head -n 200 \
> /tmp/snippets.txt
Dans le code Node, chargez ces snippets si disponibles, sinon laissez la liste vide:
// src/lib/snippets.ts
import { readFileSync } from 'node:fs';
export function loadSnippetsFromFile(path: string) {
try {
const content = readFileSync(path, 'utf8');
const chunks = content.split(/\n(?=src\/)/).slice(0, 3); // heuristique
return chunks.map((c, i) => ({ path: `snippet-${i + 1}.txt`, snippet: c.slice(0, 1200) }));
} catch {
return [];
}
}
Cette approche garde le contexte bref, ce qui réduit les tokens envoyés à Claude et améliore la pertinence.
Valider, corriger et persister la sortie
Après l’appel modèle, validez strictement le JSON avec AJV. En cas d’échec, renvoyez les erreurs et la sortie invalide à Claude pour correction. Enfin, normalisez et écrivez le fichier spec.
// src/lib/validate.ts
import Ajv from 'ajv';
import { TechSpecSchema } from '../spec-schema.js';
const ajv = new Ajv({ allErrors: true, strict: true, allowUnionTypes: false });
export function validateSpec(json: unknown) {
const validate = ajv.compile(TechSpecSchema as any);
const ok = validate(json);
return { ok, errors: validate.errors ?? [] };
}
export function normalizeSpec<T extends Record<string, any>>(spec: T): T {
function trimStr(v: unknown) {
return typeof v === 'string' ? v.trim() : v;
}
function unique(arr: string[]) {
return Array.from(new Set(arr.map(a => a.trim()).filter(Boolean)));
}
const s = structuredClone(spec);
if (typeof s.summary === 'string') s.summary = trimStr(s.summary);
if (typeof s.contexte === 'string') s.contexte = trimStr(s.contexte);
if (Array.isArray(s.decisions)) s.decisions = unique(s.decisions);
if (Array.isArray(s.contraintes)) s.contraintes = unique(s.contraintes);
if (Array.isArray(s.acceptance_criteria)) s.acceptance_criteria = unique(s.acceptance_criteria);
if (Array.isArray(s.taches)) {
s.taches = s.taches.map((t: any) => ({
...t,
title: trimStr(t.title),
dependencies: Array.isArray(t.dependencies) ? unique(t.dependencies) : undefined
}));
}
if (s.estimations?.total_hours != null) {
s.estimations.total_hours = Math.max(0, Math.min(1000, Number(s.estimations.total_hours)));
}
if (s.estimations?.confidence != null) {
s.estimations.confidence = Math.max(0, Math.min(1, Number(s.estimations.confidence)));
}
return s;
}
Stratégie de réparation: construisez un nouveau prompt minimal qui inclut les erreurs AJV et la dernière sortie pour que Claude renvoie une version corrigée.
// src/lib/repair.ts
export function buildRepairPrompt(prev: any, errors: any[]) {
const errText = JSON.stringify(errors, null, 2);
const prevText = JSON.stringify(prev);
return [
'Le JSON suivant ne valide pas le schéma. Corrige-le strictement sans ajouter de nouveaux champs.',
'Erreurs AJV:',
errText,
'Dernière sortie JSON:',
prevText
].join('\n\n');
}
Enfin, écriture sur disque, création de branche et commentaire GitHub:
// src/lib/persist.ts
import { mkdirSync, writeFileSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { env } from '../env.js';
export function saveSpec(repoPath: string, issueNumber: number, spec: unknown) {
mkdirSync(`${repoPath}/specs`, { recursive: true });
const path = `${repoPath}/specs/spec-${issueNumber}.json`;
writeFileSync(path, JSON.stringify(spec, null, 2) + '\n', 'utf8');
return path;
}
export function gitCommitBranch(repoPath: string, branch: string, filePath: string, message: string) {
execSync(`git checkout -B ${branch}`, { cwd: repoPath, stdio: 'inherit' });
execSync(`git add ${filePath}`, { cwd: repoPath, stdio: 'inherit' });
execSync(`git commit -m "${message}"`, { cwd: repoPath, stdio: 'inherit' });
}
export async function commentIssue(repo: string, issueNumber: number, body: string) {
const res = await fetch(`https://api.github.com/repos/${repo}/issues/${issueNumber}/comments`, {
method: 'POST',
headers: {
'User-Agent': 'gh-specs-bot',
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${env.githubToken}`
},
body: JSON.stringify({ body })
});
if (!res.ok) throw new Error(`Erreur commentaire GitHub: ${res.status} ${await res.text()}`);
}
export async function openPR(repo: string, base: string, head: string, title: string, body: string) {
const res = await fetch(`https://api.github.com/repos/${repo}/pulls`, {
method: 'POST',
headers: {
'User-Agent': 'gh-specs-bot',
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${env.githubToken}`
},
body: JSON.stringify({ title, body, base, head })
});
if (!res.ok) throw new Error(`Erreur ouverture PR: ${res.status} ${await res.text()}`);
return res.json();
}
Pour un commentaire synthétique, générez un petit résumé et, si vous avez remplacé une spec existante, affichez un diff minimal (ex: via json-diff) et collez un aperçu des changements.
CLI d’orchestration et hooks CI
La CLI orchestre tout: récupération du ticket, appel Claude, validation/repair, persistance, commentaire et PR optionnelle. Elle propose un mode dry-run pour visualiser le JSON sans écrire.
// src/index.ts
import { env } from './env.js';
import { Command } from 'commander';
import { fetchIssue } from './lib/github.js';
import { loadSnippetsFromFile } from './lib/snippets.js';
import { ClaudeClient, SYSTEM_PROMPT, buildUserPrompt } from './lib/claude.js';
import { validateSpec, normalizeSpec } from './lib/validate.js';
import { buildRepairPrompt } from './lib/repair.js';
import { saveSpec, gitCommitBranch, commentIssue, openPR } from './lib/persist.js';
const program = new Command();
program
.name('spec-from-issue')
.argument('<repo>', 'ex: org/repo')
.argument('<issueNumber>', 'ex: 123')
.option('--repo-path <path>', 'chemin local du repo', process.cwd())
.option('--snippets <file>', 'fichier de snippets pré-extraits (optionnel)')
.option('--update-comment', 'ajouter un commentaire dans l’issue', false)
.option('--open-pr', 'ouvrir une PR avec la spec', false)
.option('--dry-run', 'ne pas écrire, afficher seulement', false)
.action(async (repo, issueNumberStr, opts) => {
const issueNumber = Number(issueNumberStr);
const gh = await fetchIssue(repo, issueNumber);
const snippets = opts.snippets ? loadSnippetsFromFile(opts.snippets) : [];
const userPrompt = buildUserPrompt({
repo,
issueNumber,
title: gh.title,
body: gh.body,
comments: gh.comments,
codeSnippets: snippets
});
const claude = new ClaudeClient(env.anthropicKey);
const runOnce = async (maxTokens: number) => {
const res = await claude.generateSpec(
{ system: SYSTEM_PROMPT, user: userPrompt },
{ maxOutputTokens: maxTokens }
);
return { value: res, stop_reason: res.meta.stop_reason };
};
const { json, meta } = await (async () => {
const first = await runOnce(2000);
if (first.stop_reason === 'max_tokens') {
const second = await runOnce(3000);
return second.value;
}
return first.value;
})();
console.log(JSON.stringify({ event: 'claude_response', id: meta.id, usage: meta.usage }, null, 2));
let { ok, errors } = validateSpec(json);
let spec = json;
if (!ok) {
const repairPrompt = buildRepairPrompt(json, errors);
const repaired = await claude.generateSpec(
{ system: SYSTEM_PROMPT, user: repairPrompt },
{ maxOutputTokens: 2000 }
);
spec = repaired.json;
const v2 = validateSpec(spec);
ok = v2.ok;
errors = v2.errors;
}
if (!ok) {
console.error('Échec de validation AJV:', errors);
process.exitCode = 1;
return;
}
const normalized = normalizeSpec(spec);
if (opts.dry_run) {
console.log(JSON.stringify(normalized, null, 2));
return;
}
const path = saveSpec(opts.repo_path, issueNumber, normalized);
const branch = `spec/issue-${issueNumber}`;
const msg = `chore(spec): add spec for issue #${issueNumber}`;
gitCommitBranch(opts.repo_path, branch, path, msg);
if (opts.update_comment) {
const summary = typeof normalized.summary === 'string' ? normalized.summary : 'Résumé indisponible';
await commentIssue(
repo,
issueNumber,
[
`Spec générée pour #${issueNumber} sur la branche ${branch}.`,
'',
`Résumé: ${summary}`,
'',
'Fichier:',
`specs/spec-${issueNumber}.json`
].join('\n')
);
}
if (opts.open_pr) {
const pr = await openPR(repo, 'main', branch, `Spec technique pour #${issueNumber}`, 'Spécification JSON générée automatiquement.');
console.log(JSON.stringify({ event: 'pr_opened', url: pr.html_url }, null, 2));
}
});
program.parseAsync();
Pour l’intégration CI/CD, créez une GitHub Action déclenchée par un label spécifique ou un commentaire de commande. Par exemple, déclencher la génération lorsque l’issue reçoit le label needs-spec:
# .github/workflows/spec-from-issue.yml
name: Spec from Issue
on:
issues:
types: [labeled]
jobs:
build-spec:
if: github.event.label.name == 'needs-spec'
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '18'
- run: npm ci
- run: npm run build
- name: Generate spec
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
node dist/index.js ${{ github.repository }} ${{ github.event.issue.number }} --update-comment --open-pr
Ce workflow crée une branche, pousse la spec et ouvre une PR automatiquement lorsqu’une issue est labellisée.
Qualité, limites et itérations
La qualité dépend du cadrage du schéma, du prompt et du contexte. Testez votre pipeline avec des tickets exemples et stabilisez les attentes avec des fichiers golden. Par exemple, placez des tickets anonymisés dans fixtures/ et vérifiez que la sortie est stable (hors champs textuels longs) grâce à des snapshots tolérants. Dans vos tests, autorisez une marge, comme la variation de wording, mais exigez la présence des sections principales.
Le prompt tuning se fait en ajoutant des descriptions explicites dans le schéma, en montrant au modèle 1–2 exemples positifs et en rappelant des tailles maximales par section. Réduire la verbosité au niveau du schéma (maxLength, maxItems) est plus robuste que des consignes textuelles.
Contre les hallucinations, les enums et formats stricts (uri, date-time, patterns d’ID) font une réelle différence. Gardez le champ status pour que le modèle puisse signaler un manque de contexte et demander des précisions, plutôt que d’inventer. En complément, compressez le contexte: privilégiez 3–5 extraits vraiment utiles; cela réduit les coûts et force la concentration du modèle. Sonnet est un bon compromis qualité/prix; vous pouvez basculer vers Haiku pour des brouillons rapides, puis revalider avec Sonnet.
Côté fiabilité, implémentez des timeouts et retry avec backoff sur GitHub et Anthropic, logguez l’ID de requête et les tokens utilisés, et imposez un budget de tokens par run pour maîtriser les coûts. En cas de validation répétée en échec, arrêtez proprement et commentez l’issue avec status=insufficient_context et une liste de questions à l’équipe.
Extensions utiles
Vous pouvez enrichir l’outil avec plusieurs automations utiles. La génération de sous-issues GitHub depuis taches[] permet de planifier l’exécution; chaque tâche devient une issue assignable avec la priorité et l’estimation. La création d’une description de PR à partir de la spec offre un template clair avec contexte et critères d’acceptation. Lien vers la QA: générez une checklist testable depuis acceptance_criteria, que vous pouvez coller dans la PR ou un ticket QA. L’export Markdown/Confluence est simple à partir du JSON validé, via un petit renderer. Enfin, un cache de contexte (issue → snippets) par disque ou KV réduit drastiquement les tokens nécessaires lors des itérations successives.
Exemple pour créer des sous-issues à partir de taches[]:
// src/ext/subissues.ts
export async function createSubIssues(repo: string, parentIssue: number, tasks: { title: string }[]) {
for (const t of tasks) {
const title = `[Spec #${parentIssue}] ${t.title}`;
const body = `Créée automatiquement depuis la spec de #${parentIssue}.`;
const res = await fetch(`https://api.github.com/repos/${repo}/issues`, {
method: 'POST',
headers: {
'User-Agent': 'gh-specs-bot',
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`
},
body: JSON.stringify({ title, body, labels: ['from-spec'] })
});
if (!res.ok) console.error('Erreur création sous-issue:', await res.text());
}
}
Checklist
Avant de partager l’outil à l’équipe, relisez le code et les prompts pour vérifier la cohérence des champs et l’absence d’ambiguïtés. Testez les commandes de bout en bout avec 2–3 tickets représentatifs, puis inspectez les fichiers JSON produits et les PR générées. Enfin, publiez une première version interne, documentez la commande, le workflow GitHub Action et les variables d’environnement, et invitez l’équipe à ajouter le label needs-spec pour déclencher la génération.
Conclusion
Avec un schéma bien pensé, un prompting strict et une validation systématique, vous transformez des tickets bruts en spécifications techniques actionnables, versionnées et prêtes à l’industrialisation. Ce flux réduit les allers-retours, clarifie l’acceptation et crée un point d’ancrage pour la planification, la QA et la documentation.
Ressources
- Anthropic SDK Node.js et sorties structurées: https://docs.anthropic.com
- AJV (JSON Schema Validator): https://ajv.js.org
- JSON Schema (2020-12): https://json-schema.org
- GitHub REST API v3: https://docs.github.com/en/rest
- Commander (CLI): https://github.com/tj/commander.js
- ripgrep: https://github.com/BurntSushi/ripgrep
- undici (fetch Node): https://github.com/nodejs/undici