@spec-wave/cli 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/spec-wave.mjs CHANGED
@@ -124,4 +124,15 @@ program
124
124
  await decompose(options).catch(err => { console.error(err.message); process.exit(1); });
125
125
  });
126
126
 
127
+ program
128
+ .command('implement')
129
+ .description('Aciona o spec-kit implement para uma Story (todas as tasks) ou uma Task')
130
+ .argument('<issue>', 'Número da issue (Story ou Task), ex.: 12 ou #12')
131
+ .option('--feature-dir <path>', 'Caminho do docs/features/<slug> (sobrescreve a resolução automática)')
132
+ .option('--dry-run', 'Monta o contexto e imprime o comando sem executar o spec-kit')
133
+ .action(async (issue, options) => {
134
+ const { implement } = await import('../src/commands/implement.mjs');
135
+ await implement({ issue, ...options }).catch(err => { console.error(err.message); process.exit(1); });
136
+ });
137
+
127
138
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Setup spec-driven GitHub workflow with Projects v2, labels, issue templates, and AI-powered Actions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -183,6 +183,56 @@ export async function addSubIssue(token, parentIssueId, childIssueId) {
183
183
  `, { issueId: parentIssueId, subIssueId: childIssueId });
184
184
  }
185
185
 
186
+ // Lista as sub-issues de uma issue (pelo node id da issue pai). Retorna
187
+ // [{ number, title, body, nodeId, labels: [nome...] }]. Usado pelo comando
188
+ // `implement` para coletar as Tasks de uma Story.
189
+ export async function listSubIssues(token, issueNodeId) {
190
+ const client = makeClient(token);
191
+ const result = await client(`
192
+ query SubIssues($id: ID!) {
193
+ node(id: $id) {
194
+ ... on Issue {
195
+ subIssues(first: 100) {
196
+ nodes {
197
+ id
198
+ number
199
+ title
200
+ body
201
+ labels(first: 20) { nodes { name } }
202
+ }
203
+ }
204
+ }
205
+ }
206
+ }
207
+ `, { id: issueNodeId });
208
+ const nodes = result.node?.subIssues?.nodes || [];
209
+ return nodes.map(n => ({
210
+ number: n.number,
211
+ title: n.title,
212
+ body: n.body || '',
213
+ nodeId: n.id,
214
+ labels: (n.labels?.nodes || []).map(l => l.name),
215
+ }));
216
+ }
217
+
218
+ // Lê o parent (issue pai) de uma sub-issue. Retorna { number, title, nodeId }
219
+ // ou null se a issue não tiver pai. Usado para subir a cadeia Task→Story→Feature.
220
+ export async function getIssueParent(token, issueNodeId) {
221
+ const client = makeClient(token);
222
+ const result = await client(`
223
+ query IssueParent($id: ID!) {
224
+ node(id: $id) {
225
+ ... on Issue {
226
+ parent { id number title }
227
+ }
228
+ }
229
+ }
230
+ `, { id: issueNodeId });
231
+ const parent = result.node?.parent;
232
+ if (!parent) return null;
233
+ return { number: parent.number, title: parent.title, nodeId: parent.id };
234
+ }
235
+
186
236
  // Define o valor de um campo SINGLE_SELECT para um item do Project.
187
237
  export async function setItemSingleSelect(token, projectId, itemId, fieldId, optionId) {
188
238
  const client = makeClient(token);
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { execFileSync } from 'node:child_process';
3
2
  import { resolveToken } from '../api/auth.mjs';
4
- import { getIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
3
+ import { getIssue, createIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
4
+ import { addSubIssue } from '../api/github-graphql.mjs';
5
5
  import { generateDocument } from '../lib/claude.mjs';
6
6
  import { slugify } from '../lib/slugify.mjs';
7
7
 
@@ -12,11 +12,12 @@ Responda APENAS com JSON válido neste formato:
12
12
  {
13
13
  "stories": [
14
14
  {
15
- "title": "[STORY] Título da story no formato 'Como <perfil>, quero <objetivo>, para <benefício>'",
16
- "body": "Descrição da story com contexto e critérios de aceite relevantes",
15
+ "title": "Título curto da story (apenas a parte 'quero', sem prefixo)",
16
+ "userStory": "Como <perfil>, quero <objetivo>, para <benefício>",
17
+ "body": "Descrição complementar da story com contexto e critérios de aceite relevantes",
17
18
  "tasks": [
18
19
  {
19
- "title": "[TASK] Título técnico da task",
20
+ "title": "Título técnico curto da task (sem prefixo)",
20
21
  "body": "Descrição técnica detalhada"
21
22
  }
22
23
  ]
@@ -25,9 +26,11 @@ Responda APENAS com JSON válido neste formato:
25
26
  }
26
27
 
27
28
  Regras:
29
+ - "title" deve ser CURTO (máx. ~60 caracteres): apenas a parte "quero" da user story, sem o "Como" nem o "para", e sem prefixo. Ex.: "visualizar meus repositórios em layout responsivo"
30
+ - "userStory" deve trazer a user story completa no formato "Como <perfil>, quero <objetivo>, para <benefício>"
31
+ - "body" é texto complementar (contexto, critérios de aceite); não repita o título
28
32
  - Cada Story deve ter 2–5 Tasks associadas
29
- - Stories devem seguir o formato de User Story
30
- - Tasks devem ser atividades técnicas concretas
33
+ - Tasks devem ser atividades técnicas concretas, com "title" curto e "body" detalhado
31
34
  - Gere entre 3 e 7 Stories por Feature`;
32
35
 
33
36
  export async function decompose({ issueNumber }) {
@@ -75,26 +78,41 @@ export async function decompose({ issueNumber }) {
75
78
  decomposition = JSON.parse(jsonMatch[0]);
76
79
  }
77
80
 
81
+ // node id da Feature — necessário para vincular as stories como sub-issues.
82
+ const featureNodeId = issue.node_id;
83
+
78
84
  const created = [];
79
85
 
80
86
  for (const story of decomposition.stories) {
81
87
  console.log(`Criando story: ${story.title}`);
82
- const storyOutput = execFileSync(
83
- 'gh',
84
- ['issue', 'create', '--title', story.title, '--body', story.body, '--label', '[STORY]'],
85
- { encoding: 'utf-8' }
86
- ).trim();
87
- const storyUrl = storyOutput.trim();
88
- created.push({ type: 'story', title: story.title, url: storyUrl });
88
+ const storyTitle = `[STORY] ${story.title}`;
89
+ // Corpo: user story completa (Como/quero/para) + texto complementar.
90
+ const storyBody = [story.userStory, story.body]
91
+ .map(s => (s || '').trim())
92
+ .filter(Boolean)
93
+ .join('\n\n') || '_(sem descrição)_';
94
+ const createdStory = await createIssue(token, owner, repo, storyTitle, storyBody, ['[STORY]']);
95
+ created.push({ type: 'story', title: storyTitle, url: createdStory.url });
96
+
97
+ // Vincula a story como sub-issue da Feature (relação nativa do GitHub).
98
+ try {
99
+ await addSubIssue(token, featureNodeId, createdStory.nodeId);
100
+ } catch (err) {
101
+ console.warn(` Story #${createdStory.number} criada, mas falhou ao vincular à Feature: ${err.message}`);
102
+ }
89
103
 
90
104
  for (const task of story.tasks || []) {
91
105
  console.log(` Criando task: ${task.title}`);
92
- const taskBody = `${task.body}\n\n_Story pai: ${storyUrl}_`;
93
- execFileSync(
94
- 'gh',
95
- ['issue', 'create', '--title', task.title, '--body', taskBody, '--label', '[TASK]'],
96
- { encoding: 'utf-8' }
97
- );
106
+ const taskTitle = `[TASK] ${task.title}`;
107
+ const taskBody = `${task.body}\n\n_Story pai: ${createdStory.url}_`;
108
+ const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
109
+
110
+ // Vincula a task como sub-issue da Story.
111
+ try {
112
+ await addSubIssue(token, createdStory.nodeId, createdTask.nodeId);
113
+ } catch (err) {
114
+ console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular à Story: ${err.message}`);
115
+ }
98
116
  }
99
117
  }
100
118
 
@@ -0,0 +1,222 @@
1
+ import * as p from '@clack/prompts';
2
+ import chalk from 'chalk';
3
+ import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { execSync } from 'node:child_process';
5
+ import path from 'node:path';
6
+ import { resolveToken } from '../api/auth.mjs';
7
+ import { CONFIG_FILE } from '../config.mjs';
8
+ import { getIssue } from '../api/github-rest.mjs';
9
+ import { listSubIssues, getIssueParent } from '../api/github-graphql.mjs';
10
+ import { detectIssueType } from '../lib/issue-type.mjs';
11
+ import { slugify } from '../lib/slugify.mjs';
12
+
13
+ // Diretório onde montamos o arquivo de contexto entregue ao spec-kit.
14
+ const WORK_DIR = '.spec-wave';
15
+
16
+ // Sobe a cadeia de pais (Task → Story → Feature) até achar uma issue do tipo
17
+ // "Feature" e devolve seu título (para resolver docs/features/<slug>). Limita a
18
+ // profundidade para evitar loops em dados inconsistentes.
19
+ async function resolveFeatureTitle(token, startNodeId) {
20
+ let current = startNodeId;
21
+ for (let depth = 0; depth < 5 && current; depth++) {
22
+ const parent = await getIssueParent(token, current);
23
+ if (!parent) return null;
24
+ if (detectIssueType({ title: parent.title }) === 'Feature') return parent.title;
25
+ current = parent.nodeId;
26
+ }
27
+ return null;
28
+ }
29
+
30
+ // Lê spec.md/plan.md de um docs/features/<slug> se existirem.
31
+ function readSpecPlan(featureDir) {
32
+ const specPath = path.join(featureDir, 'spec.md');
33
+ const planPath = path.join(featureDir, 'plan.md');
34
+ return {
35
+ specPath,
36
+ planPath,
37
+ spec: existsSync(specPath) ? readFileSync(specPath, 'utf-8') : null,
38
+ plan: existsSync(planPath) ? readFileSync(planPath, 'utf-8') : null,
39
+ };
40
+ }
41
+
42
+ // Monta o markdown de contexto que será entregue ao spec-kit implement.
43
+ function buildContext({ type, issue, tasks, spec, plan, specPath, planPath }) {
44
+ const lines = [];
45
+ lines.push(`# Contexto de implementação — ${type} #${issue.number}`);
46
+ lines.push('');
47
+ lines.push(`**${type}:** ${issue.title}`);
48
+ if (issue.body && issue.body.trim()) {
49
+ lines.push('');
50
+ lines.push(issue.body.trim());
51
+ }
52
+
53
+ lines.push('');
54
+ lines.push(`## Tasks a implementar (${tasks.length})`);
55
+ for (const t of tasks) {
56
+ lines.push('');
57
+ lines.push(`### #${t.number} ${t.title}`);
58
+ if (t.body && t.body.trim()) lines.push(t.body.trim());
59
+ }
60
+
61
+ if (spec) {
62
+ lines.push('');
63
+ lines.push(`## spec.md (${specPath})`);
64
+ lines.push(spec.trim());
65
+ }
66
+ if (plan) {
67
+ lines.push('');
68
+ lines.push(`## plan.md (${planPath})`);
69
+ lines.push(plan.trim());
70
+ }
71
+
72
+ lines.push('');
73
+ return lines.join('\n');
74
+ }
75
+
76
+ // Substitui os placeholders do template de comando do spec-kit.
77
+ function renderCommand(template, vars) {
78
+ return template.replace(/\{(\w+)\}/g, (m, key) => (key in vars ? vars[key] : m));
79
+ }
80
+
81
+ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dryRun }) {
82
+ const issueNumber = parseInt(String(issueArg).replace('#', ''), 10);
83
+ if (!Number.isInteger(issueNumber)) {
84
+ p.log.error(`Issue inválida: "${issueArg}". Use o número da issue, ex.: 12 ou #12.`);
85
+ process.exitCode = 1;
86
+ return;
87
+ }
88
+
89
+ // 1. Config local (.spec-wave.json) — owner/repo e bloco opcional specKit.
90
+ const configPath = path.join(process.cwd(), CONFIG_FILE);
91
+ if (!existsSync(configPath)) {
92
+ p.log.error(`Repositório não inicializado (sem ${CONFIG_FILE}). Rode \`spec-wave init\` primeiro.`);
93
+ process.exitCode = 1;
94
+ return;
95
+ }
96
+ let config;
97
+ try {
98
+ config = JSON.parse(readFileSync(configPath, 'utf-8'));
99
+ } catch (err) {
100
+ p.log.error(`${CONFIG_FILE} corrompido: ${err.message}`);
101
+ process.exitCode = 1;
102
+ return;
103
+ }
104
+ const { owner, repo } = config;
105
+ if (!owner || !repo) {
106
+ p.log.error(`${CONFIG_FILE} não contém owner/repo. Rode \`spec-wave init\` novamente.`);
107
+ process.exitCode = 1;
108
+ return;
109
+ }
110
+
111
+ let token;
112
+ try {
113
+ token = await resolveToken();
114
+ } catch (err) {
115
+ p.log.error(err.message);
116
+ process.exitCode = 1;
117
+ return;
118
+ }
119
+
120
+ p.intro(chalk.bold(`spec-wave implement #${issueNumber}`));
121
+
122
+ // 2. Lê a issue alvo.
123
+ let issue;
124
+ try {
125
+ issue = await getIssue(token, owner, repo, issueNumber);
126
+ } catch (err) {
127
+ p.log.error(`Não foi possível ler a issue #${issueNumber}: ${err.message}`);
128
+ process.exitCode = 1;
129
+ return;
130
+ }
131
+
132
+ // 3. Detecta o tipo e monta a lista de tasks.
133
+ const type = detectIssueType(issue);
134
+ let tasks;
135
+ if (type === 'Story') {
136
+ const subs = await listSubIssues(token, issue.node_id);
137
+ tasks = subs.filter(s => detectIssueType({ title: s.title, labels: s.labels }) === 'Task');
138
+ if (tasks.length === 0) {
139
+ p.log.error(`Story #${issueNumber} não tem Tasks (sub-issues) para implementar.`);
140
+ process.exitCode = 1;
141
+ return;
142
+ }
143
+ p.log.info(`Story com ${tasks.length} task(s): ${tasks.map(t => `#${t.number}`).join(', ')}`);
144
+ } else if (type === 'Task') {
145
+ tasks = [{ number: issue.number, title: issue.title, body: issue.body || '' }];
146
+ p.log.info(`Task única #${issueNumber}.`);
147
+ } else {
148
+ p.log.error(
149
+ `implement só aceita Story ou Task. Issue #${issueNumber} é do tipo ${type || 'desconhecido'}.`
150
+ );
151
+ process.exitCode = 1;
152
+ return;
153
+ }
154
+
155
+ // 4. Resolve spec.md/plan.md da Feature (enriquecimento opcional).
156
+ let featureDir = featureDirOpt;
157
+ if (!featureDir) {
158
+ const featureTitle = await resolveFeatureTitle(token, issue.node_id);
159
+ if (featureTitle) featureDir = path.join('docs', 'features', slugify(featureTitle));
160
+ }
161
+ let specPlan = { spec: null, plan: null, specPath: null, planPath: null };
162
+ if (featureDir && existsSync(featureDir)) {
163
+ specPlan = readSpecPlan(featureDir);
164
+ } else if (featureDir) {
165
+ p.log.warn(`Diretório da feature não encontrado (${featureDir}); seguindo só com as tasks.`);
166
+ } else {
167
+ p.log.warn('Não foi possível resolver a Feature; seguindo só com as tasks (use --feature-dir).');
168
+ }
169
+
170
+ // 5. Monta e grava o arquivo de contexto.
171
+ const context = buildContext({ type, issue, tasks, ...specPlan });
172
+ mkdirSync(WORK_DIR, { recursive: true });
173
+ const tasksFile = path.join(WORK_DIR, `implement-${issueNumber}.md`);
174
+ writeFileSync(tasksFile, context);
175
+ p.log.success(`Contexto montado em ${chalk.cyan(tasksFile)}.`);
176
+
177
+ // 6. Aciona o spec-kit (comando configurável).
178
+ const template = process.env.SPEC_WAVE_IMPLEMENT_CMD || config.specKit?.command;
179
+ const vars = {
180
+ tasksFile,
181
+ specFile: specPlan.specPath || '',
182
+ planFile: specPlan.planPath || '',
183
+ issue: String(issueNumber),
184
+ type,
185
+ title: issue.title,
186
+ };
187
+
188
+ if (!template) {
189
+ p.log.warn('Comando do spec-kit não configurado.');
190
+ p.note(
191
+ `Configure em ${CONFIG_FILE}:\n` +
192
+ ` "specKit": { "command": "<comando do spec-kit com placeholders>" }\n` +
193
+ 'ou defina a env SPEC_WAVE_IMPLEMENT_CMD.\n\n' +
194
+ 'Placeholders: {tasksFile} {specFile} {planFile} {issue} {type} {title}',
195
+ 'Como acionar o spec-kit'
196
+ );
197
+ p.outro(`Contexto pronto em ${tasksFile}. Acione o spec-kit manualmente com esse arquivo.`);
198
+ return;
199
+ }
200
+
201
+ const command = renderCommand(template, vars);
202
+
203
+ if (dryRun) {
204
+ p.note(command, 'Comando que seria executado (--dry-run)');
205
+ p.outro(`Dry-run: nada executado. Contexto em ${tasksFile}.`);
206
+ return;
207
+ }
208
+
209
+ p.log.step(`Executando: ${chalk.dim(command)}`);
210
+ try {
211
+ execSync(command, { stdio: 'inherit' });
212
+ } catch (err) {
213
+ p.log.error(`spec-kit implement falhou: ${err.message}`);
214
+ process.exitCode = 1;
215
+ return;
216
+ }
217
+
218
+ p.outro(
219
+ `${chalk.green('✓')} Implementação acionada para ${type} #${issueNumber}.\n` +
220
+ ' Próximo: revise as mudanças, abra o PR e mova o card para 👀 Code Review.'
221
+ );
222
+ }
@@ -0,0 +1,37 @@
1
+ import { TYPE_LABELS } from '../config.mjs';
2
+
3
+ // Mapa de prefixo de label (ex.: "[STORY]") → nome canônico do tipo ("Story").
4
+ // Derivado de TYPE_LABELS para manter uma única fonte da verdade.
5
+ const TAG_TO_TYPE = Object.fromEntries(
6
+ TYPE_LABELS.map(l => [l.name.toUpperCase(), capitalize(l.name.replace(/[[\]]/g, ''))])
7
+ );
8
+
9
+ function capitalize(s) {
10
+ const lower = s.toLowerCase();
11
+ // RFC fica em caixa alta; os demais tipos seguem Capitalizado (Story, Task...).
12
+ return lower === 'rfc' ? 'RFC' : lower.charAt(0).toUpperCase() + lower.slice(1);
13
+ }
14
+
15
+ // Detecta o tipo canônico de uma issue ('Story', 'Task', 'Feature', ...) a partir
16
+ // do prefixo do título ([STORY], [TASK], ...) com fallback nas labels. Retorna
17
+ // null quando não é possível determinar.
18
+ export function detectIssueType(issue) {
19
+ if (!issue) return null;
20
+
21
+ // 1. Prefixo do título: "[STORY] ..." → "Story".
22
+ const titleMatch = String(issue.title || '').match(/^\s*(\[[^\]]+\])/);
23
+ if (titleMatch) {
24
+ const type = TAG_TO_TYPE[titleMatch[1].toUpperCase()];
25
+ if (type) return type;
26
+ }
27
+
28
+ // 2. Fallback: varrer as labels da issue procurando um dos prefixos de tipo.
29
+ const labels = Array.isArray(issue.labels) ? issue.labels : [];
30
+ for (const label of labels) {
31
+ const name = (typeof label === 'string' ? label : label?.name) || '';
32
+ const type = TAG_TO_TYPE[name.toUpperCase()];
33
+ if (type) return type;
34
+ }
35
+
36
+ return null;
37
+ }