@spec-wave/cli 0.8.0 → 0.8.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/README.md CHANGED
@@ -72,7 +72,7 @@ Ferramenta Node.js que configura e opera o fluxo via linha de comando.
72
72
  | `decompose` | Decompõe Feature em Stories e Tasks (usado pelo GitHub Action) |
73
73
  | `code-review` | Move Feature para Code Review ao abrir PR (usado pelo GitHub Action) |
74
74
  | `qa` | Move Feature para QA ao aprovar PR (usado pelo GitHub Action) |
75
- | `implement` | Aciona o spec-kit localmente para implementar uma Story ou Task |
75
+ | `implement` | Aciona o spec-kit localmente para implementar uma Feature (Stories pendentes em ordem de dependência), Story ou Task |
76
76
  | `uninstall` | Remove labels, workflows e `.spec-wave.json` |
77
77
 
78
78
  ### GitHub Actions (instalados pelo `init`)
package/bin/spec-wave.mjs CHANGED
@@ -184,8 +184,8 @@ program
184
184
 
185
185
  program
186
186
  .command('implement')
187
- .description('Aciona o spec-kit implement para uma Story (todas as tasks) ou uma Task')
188
- .argument('<issue>', 'Número da issue (Story ou Task), ex.: 12 ou #12')
187
+ .description('Aciona o spec-kit implement para uma Feature (Stories pendentes em ordem de dependência), uma Story (todas as tasks) ou uma Task')
188
+ .argument('<issue>', 'Número da issue (Feature, Story ou Task), ex.: 12 ou #12')
189
189
  .option('--feature-dir <path>', 'Caminho do docs/features/<slug> (sobrescreve a resolução automática)')
190
190
  .option('--dry-run', 'Monta o contexto e imprime o comando sem executar o spec-kit')
191
191
  .action(async (issue, options) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.8.0",
3
+ "version": "0.8.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": {
@@ -5,19 +5,24 @@ import { execSync } from 'node:child_process';
5
5
  import path from 'node:path';
6
6
  import { resolveToken } from '../api/auth.mjs';
7
7
  import {
8
- CONFIG_FILE, STAGE_DEVELOPMENT, STAGE_CODE_REVIEW, STAGE_DONE,
8
+ CONFIG_FILE, STAGE_DEVELOPMENT, STAGE_CODE_REVIEW, STAGE_DONE, STAGE_ORDER,
9
9
  PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE,
10
10
  } from '../config.mjs';
11
11
  import { getIssue, listIssueComments, listBlockedBy } from '../api/github-rest.mjs';
12
- import { listSubIssues, getIssueParent } from '../api/github-graphql.mjs';
12
+ import { listSubIssues, getIssueParent, addProjectItem, getItemSingleSelectValue } from '../api/github-graphql.mjs';
13
13
  import { detectIssueType } from '../lib/issue-type.mjs';
14
14
  import { slugify } from '../lib/slugify.mjs';
15
- import { parseDependencies } from '../lib/dependencies.mjs';
15
+ import { parseDependencies, orderStories, formatDependencyLine } from '../lib/dependencies.mjs';
16
+ import { loadProjectConfig, resolveField } from '../lib/board.mjs';
16
17
  import { extractPathsFromPlan, buildCodeDigest } from '../lib/code-digest.mjs';
17
18
 
18
19
  // Diretório onde montamos o arquivo de contexto entregue ao spec-kit.
19
20
  const WORK_DIR = '.spec-wave';
20
21
 
22
+ // Caps dos comentários anexados ao contexto (por issue).
23
+ const MAX_COMMENTS_PER_ISSUE = 15;
24
+ const MAX_COMMENT_CHARS = 2000;
25
+
21
26
  // Sobe a cadeia de pais (Task → Story → Feature) até achar uma issue do tipo
22
27
  // "Feature" e devolve { number, title } — usado para resolver docs/features/<slug>
23
28
  // e para as instruções de fim de Story (mover a Feature para Code Review). Limita
@@ -47,6 +52,89 @@ function readSpecPlan(featureDir) {
47
52
  };
48
53
  }
49
54
 
55
+ // ── Blocos compartilhados entre buildContext (Story/Task) e buildFeatureContext ──
56
+
57
+ // Explica os dois campos do board (Etapa × Status) — abre as instruções de execução.
58
+ function boardFieldsExplainer() {
59
+ return (
60
+ 'Há **dois campos** no board com papéis diferentes — não os confunda:\n' +
61
+ `- **Etapa** (Backlog → … → ${STAGE_DEVELOPMENT} → ${STAGE_CODE_REVIEW} → … → ${STAGE_DONE}): a DIREÇÃO no kanban. Uma issue só **avança**, **nunca** volta para uma etapa anterior.\n` +
62
+ `- **Status** (${PROGRESS_TODO} → ${PROGRESS_IN_PROGRESS} → ${PROGRESS_DONE}): o **progresso dentro da etapa atual**. Ao avançar de etapa, o Status reinicia em ${PROGRESS_TODO} — exceto ao chegar na Etapa ${STAGE_DONE}, onde o Status fica **${PROGRESS_DONE}**.`
63
+ );
64
+ }
65
+
66
+ // Blockquote-resumo da regra do board — fecha as instruções de execução.
67
+ function boardRuleBlockquote() {
68
+ return (
69
+ `> **Regra do board:** a **Etapa** só avança (nunca retrocede); o **Status** (${PROGRESS_TODO}/${PROGRESS_IN_PROGRESS}/${PROGRESS_DONE}) ` +
70
+ `mede o progresso dentro da etapa atual e reinicia a cada avanço (na Etapa ${STAGE_DONE}, o Status fica ${PROGRESS_DONE}).`
71
+ );
72
+ }
73
+
74
+ // Dependências ainda abertas — logo após o cabeçalho, para máxima visibilidade.
75
+ function pushBlockedByWarnings(lines, blockedByWarnings) {
76
+ if (!blockedByWarnings || blockedByWarnings.length === 0) return;
77
+ lines.push('');
78
+ lines.push('## ⚠️ Dependências pendentes');
79
+ lines.push('');
80
+ for (const w of blockedByWarnings) lines.push(`- ${w}`);
81
+ lines.push('');
82
+ lines.push(
83
+ '**Implemente somente se tiver certeza de que a dependência não é bloqueante; ' +
84
+ 'caso contrário, pare e reporte.**'
85
+ );
86
+ }
87
+
88
+ // Comentários das issues — é onde vivem as revisões/correções feitas depois
89
+ // que spec/plan/stories foram escritos; em conflito, o comentário vence.
90
+ function pushCommentsSection(lines, comments) {
91
+ if (!comments || comments.length === 0) return;
92
+ lines.push('');
93
+ lines.push('## Comentários das issues (revisões e correções)');
94
+ lines.push('');
95
+ lines.push(
96
+ '> Comentários frequentemente **corrigem ou substituem** instruções dos documentos ' +
97
+ 'acima — em caso de conflito, o comentário mais recente prevalece.'
98
+ );
99
+ for (const group of comments) {
100
+ lines.push('');
101
+ lines.push(`### Comentários da ${group.kind} #${group.issueNumber}`);
102
+ if (group.total > group.items.length) {
103
+ lines.push('');
104
+ lines.push(`_(mostrando os ${group.items.length} mais recentes de ${group.total})_`);
105
+ }
106
+ for (const c of group.items) {
107
+ lines.push('');
108
+ lines.push(`**${c.author || 'desconhecido'}** (${c.createdAt}):`);
109
+ lines.push('');
110
+ lines.push(c.body.trim());
111
+ }
112
+ }
113
+ }
114
+
115
+ // Estado atual do código + spec.md + plan.md — fecho comum dos dois contextos.
116
+ function pushDigestSpecPlan(lines, { codeDigest, spec, plan, specPath, planPath }) {
117
+ if (codeDigest) {
118
+ lines.push('');
119
+ lines.push('## Estado atual do código');
120
+ lines.push('');
121
+ lines.push('> **NÃO reimplemente o que já existe; estenda os módulos listados abaixo.**');
122
+ lines.push('');
123
+ lines.push(codeDigest.trim());
124
+ }
125
+
126
+ if (spec) {
127
+ lines.push('');
128
+ lines.push(`## spec.md (${specPath})`);
129
+ lines.push(spec.trim());
130
+ }
131
+ if (plan) {
132
+ lines.push('');
133
+ lines.push(`## plan.md (${planPath})`);
134
+ lines.push(plan.trim());
135
+ }
136
+ }
137
+
50
138
  // Monta o markdown de contexto que será entregue ao spec-kit implement.
51
139
  function buildContext({
52
140
  type, issue, tasks, feature, siblingStories = [], spec, plan, specPath, planPath,
@@ -61,18 +149,7 @@ function buildContext({
61
149
  lines.push(issue.body.trim());
62
150
  }
63
151
 
64
- // Dependências ainda abertas — logo após o cabeçalho, para máxima visibilidade.
65
- if (blockedByWarnings.length > 0) {
66
- lines.push('');
67
- lines.push('## ⚠️ Dependências pendentes');
68
- lines.push('');
69
- for (const w of blockedByWarnings) lines.push(`- ${w}`);
70
- lines.push('');
71
- lines.push(
72
- '**Implemente somente se tiver certeza de que a dependência não é bloqueante; ' +
73
- 'caso contrário, pare e reporte.**'
74
- );
75
- }
152
+ pushBlockedByWarnings(lines, blockedByWarnings);
76
153
 
77
154
  // Modelo do board: "Etapa" (coluna do kanban) = DIREÇÃO, só avança; "Status"
78
155
  // (Todo/In Progress/Done) = PROGRESSO dentro da etapa. O desenvolvimento de
@@ -81,11 +158,7 @@ function buildContext({
81
158
  lines.push('');
82
159
  lines.push('## Instruções de execução (uma task por vez, sequencial)');
83
160
  lines.push('');
84
- lines.push(
85
- 'Há **dois campos** no board com papéis diferentes — não os confunda:\n' +
86
- `- **Etapa** (Backlog → … → ${STAGE_DEVELOPMENT} → ${STAGE_CODE_REVIEW} → … → ${STAGE_DONE}): a DIREÇÃO no kanban. Uma issue só **avança**, **nunca** volta para uma etapa anterior.\n` +
87
- `- **Status** (${PROGRESS_TODO} → ${PROGRESS_IN_PROGRESS} → ${PROGRESS_DONE}): o **progresso dentro da etapa atual**. Ao avançar de etapa, o Status reinicia em ${PROGRESS_TODO} — exceto ao chegar na Etapa ${STAGE_DONE}, onde o Status fica **${PROGRESS_DONE}**.`
88
- );
161
+ lines.push(boardFieldsExplainer());
89
162
  lines.push('');
90
163
  if (type === 'Story') {
91
164
  lines.push(
@@ -125,10 +198,7 @@ function buildContext({
125
198
  lines.push(`3. **Ao concluir:** **avance a Task #${issue.number} para a Etapa ${STAGE_DONE}** com Status **${PROGRESS_DONE}**.`);
126
199
  }
127
200
  lines.push('');
128
- lines.push(
129
- `> **Regra do board:** a **Etapa** só avança (nunca retrocede); o **Status** (${PROGRESS_TODO}/${PROGRESS_IN_PROGRESS}/${PROGRESS_DONE}) ` +
130
- `mede o progresso dentro da etapa atual e reinicia a cada avanço (na Etapa ${STAGE_DONE}, o Status fica ${PROGRESS_DONE}).`
131
- );
201
+ lines.push(boardRuleBlockquote());
132
202
 
133
203
  lines.push('');
134
204
  lines.push(`## Tasks a implementar — NESTA ORDEM (${tasks.length})`);
@@ -138,52 +208,136 @@ function buildContext({
138
208
  if (t.body && t.body.trim()) lines.push(t.body.trim());
139
209
  });
140
210
 
141
- // Comentários das issues — é onde vivem as revisões/correções feitas depois
142
- // que spec/plan/stories foram escritos; em conflito, o comentário vence.
143
- if (comments.length > 0) {
144
- lines.push('');
145
- lines.push('## Comentários das issues (revisões e correções)');
211
+ pushCommentsSection(lines, comments);
212
+ pushDigestSpecPlan(lines, { codeDigest, spec, plan, specPath, planPath });
213
+
214
+ lines.push('');
215
+ return lines.join('\n');
216
+ }
217
+
218
+ /**
219
+ * Planeja a implementação de uma Feature: separa as Stories já implementadas
220
+ * (Etapa >= reviewStage na ordem canônica) das pendentes e ordena as pendentes
221
+ * topologicamente pelas dependências. Pura — sem I/O. NUNCA lança.
222
+ *
223
+ * Stories com stage null/desconhecido contam como pendentes (mais seguro
224
+ * incluir do que pular em silêncio). O grafo é montado só com as pendentes:
225
+ * dependências para Stories puladas (ou externas) contam como satisfeitas, e o
226
+ * `cycle` retornado só acusa ciclos entre pendentes.
227
+ *
228
+ * @param {Array<{number:number, stage:string|null, dependsOn?:number[]}>} stories
229
+ * @param {{reviewStage?:string, stageOrder?:string[]}} [opts]
230
+ * @returns {{ pending: object[], skipped: object[], cycle: number[] }}
231
+ * pending: objetos originais na ordem de execução; skipped: em ordem
232
+ * crescente de number; cycle: numbers pendentes em/bloqueados por ciclo.
233
+ */
234
+ export function planFeatureImplementation(stories, {
235
+ reviewStage = STAGE_CODE_REVIEW,
236
+ stageOrder = STAGE_ORDER,
237
+ } = {}) {
238
+ const list = Array.isArray(stories) ? stories : [];
239
+ const reviewIdx = stageOrder.indexOf(reviewStage);
240
+ const skipped = [];
241
+ const pendingSet = [];
242
+ for (const s of list) {
243
+ const idx = s.stage ? stageOrder.indexOf(s.stage) : -1;
244
+ if (reviewIdx !== -1 && idx !== -1 && idx >= reviewIdx) skipped.push(s);
245
+ else pendingSet.push(s);
246
+ }
247
+ skipped.sort((a, b) => a.number - b.number);
248
+
249
+ const { order, cycle } = orderStories(
250
+ pendingSet.map(s => ({ number: s.number, dependsOn: s.dependsOn || [] }))
251
+ );
252
+ const byNumber = new Map(pendingSet.map(s => [s.number, s]));
253
+ const pending = order.map(n => byNumber.get(n)).filter(Boolean);
254
+ return { pending, skipped, cycle };
255
+ }
256
+
257
+ /**
258
+ * Monta o markdown de contexto do modo Feature (puro — testável): todas as
259
+ * Stories pendentes em ordem de execução, cada uma com suas Tasks, mais as já
260
+ * implementadas (não tocar), comentários, digest e spec/plan.
261
+ */
262
+ export function buildFeatureContext({
263
+ feature, stories, skipped = [],
264
+ spec, plan, specPath, planPath,
265
+ comments = [], codeDigest = null, blockedByWarnings = [],
266
+ }) {
267
+ const lines = [];
268
+ lines.push(`# Contexto de implementação — Feature #${feature.number}`);
269
+ lines.push('');
270
+ lines.push(`**Feature:** ${feature.title}`);
271
+ if (feature.body && feature.body.trim()) {
146
272
  lines.push('');
147
- lines.push(
148
- '> Comentários frequentemente **corrigem ou substituem** instruções dos documentos ' +
149
- 'acima — em caso de conflito, o comentário mais recente prevalece.'
150
- );
151
- for (const group of comments) {
152
- lines.push('');
153
- lines.push(`### Comentários da ${group.kind} #${group.issueNumber}`);
154
- if (group.total > group.items.length) {
155
- lines.push('');
156
- lines.push(`_(mostrando os ${group.items.length} mais recentes de ${group.total})_`);
157
- }
158
- for (const c of group.items) {
159
- lines.push('');
160
- lines.push(`**${c.author || 'desconhecido'}** (${c.createdAt}):`);
161
- lines.push('');
162
- lines.push(c.body.trim());
163
- }
164
- }
273
+ lines.push(feature.body.trim());
165
274
  }
166
275
 
167
- // Estado atual do código — evita reimplementar módulos que já existem.
168
- if (codeDigest) {
276
+ pushBlockedByWarnings(lines, blockedByWarnings);
277
+
278
+ lines.push('');
279
+ lines.push('## Instruções de execução (uma Story por vez, na ordem)');
280
+ lines.push('');
281
+ lines.push(boardFieldsExplainer());
282
+ lines.push('');
283
+ lines.push(
284
+ `Implemente as ${stories.length} story(ies) pendentes abaixo **uma de cada vez, na ordem listada** — ` +
285
+ 'a ordem já respeita as dependências entre elas (linhas `Depende de:`). Para **cada Story**, na ordem:'
286
+ );
287
+ lines.push('');
288
+ lines.push(`1. **Ao começar a Story:** garanta que ela está na Etapa **${STAGE_DEVELOPMENT}** com Status **${PROGRESS_IN_PROGRESS}** (as Tasks dela nessa Etapa com Status **${PROGRESS_TODO}**).`);
289
+ lines.push(`2. Implemente as Tasks da Story **uma de cada vez, na ordem listada**. É PROIBIDO ter mais de uma Task com Status **${PROGRESS_IN_PROGRESS}** ao mesmo tempo. Para **cada Task**:`);
290
+ lines.push(` 1. **Ao começar:** Status da Task → **${PROGRESS_IN_PROGRESS}** (a Etapa continua ${STAGE_DEVELOPMENT}).`);
291
+ lines.push(' 2. **Implemente** a Task por completo.');
292
+ lines.push(` 3. **Ao concluir:** **avance a Task para a Etapa ${STAGE_DONE}** com Status **${PROGRESS_DONE}**.`);
293
+ lines.push(`3. **Ao concluir TODAS as Tasks da Story:** faça o **commit**, abra o **Pull Request** da Story e **avance a Etapa da Story para ${STAGE_CODE_REVIEW}** (Status ${PROGRESS_TODO}).`);
294
+ lines.push('4. Só então inicie a próxima Story.');
295
+ lines.push('');
296
+ lines.push(
297
+ `**Feature #${feature.number}:** avance-a para a Etapa **${STAGE_CODE_REVIEW}** (Status ${PROGRESS_TODO}) ` +
298
+ `**somente após concluir a ÚLTIMA Story da lista**${skipped.length > 0 ? ' (as Stories já implementadas listadas abaixo não precisam ser refeitas)' : ''}. ` +
299
+ `Enquanto houver Story pendente, a Feature permanece em ${STAGE_DEVELOPMENT}.`
300
+ );
301
+ lines.push('');
302
+ lines.push(boardRuleBlockquote());
303
+
304
+ if (skipped.length > 0) {
169
305
  lines.push('');
170
- lines.push('## Estado atual do código');
306
+ lines.push(`## Stories implementadas — NÃO tocar (${skipped.length})`);
171
307
  lines.push('');
172
- lines.push('> **NÃO reimplemente o que existe; estenda os módulos listados abaixo.**');
308
+ lines.push(`> estão em ${STAGE_CODE_REVIEW} ou além; **não** as reimplemente nem as mova.`);
173
309
  lines.push('');
174
- lines.push(codeDigest.trim());
310
+ for (const s of skipped) {
311
+ lines.push(`- #${s.number} ${s.title} — Etapa atual: ${s.stage || '—'}`);
312
+ }
175
313
  }
176
314
 
177
- if (spec) {
315
+ lines.push('');
316
+ lines.push(`## Stories a implementar — NESTA ORDEM (${stories.length})`);
317
+ stories.forEach((s, i) => {
178
318
  lines.push('');
179
- lines.push(`## spec.md (${specPath})`);
180
- lines.push(spec.trim());
181
- }
182
- if (plan) {
319
+ lines.push(`### ${i + 1}. Story #${s.number} — ${s.title}`);
320
+ const depLine = formatDependencyLine(s.dependsOn || []);
321
+ if (depLine) {
322
+ lines.push('');
323
+ lines.push(depLine);
324
+ }
325
+ if (s.body && s.body.trim()) {
326
+ lines.push('');
327
+ lines.push(s.body.trim());
328
+ }
329
+ const tasks = s.tasks || [];
183
330
  lines.push('');
184
- lines.push(`## plan.md (${planPath})`);
185
- lines.push(plan.trim());
186
- }
331
+ lines.push(`#### Tasks da Story #${s.number} — NESTA ORDEM (${tasks.length})`);
332
+ tasks.forEach((t, j) => {
333
+ lines.push('');
334
+ lines.push(`##### ${j + 1}. #${t.number} ${t.title}`);
335
+ if (t.body && t.body.trim()) lines.push(t.body.trim());
336
+ });
337
+ });
338
+
339
+ pushCommentsSection(lines, comments);
340
+ pushDigestSpecPlan(lines, { codeDigest, spec, plan, specPath, planPath });
187
341
 
188
342
  lines.push('');
189
343
  return lines.join('\n');
@@ -194,6 +348,182 @@ function renderCommand(template, vars) {
194
348
  return template.replace(/\{(\w+)\}/g, (m, key) => (key in vars ? vars[key] : m));
195
349
  }
196
350
 
351
+ // Modo Feature: avalia as Stories da Feature (dependências + Etapa no board),
352
+ // pula as já implementadas (Code Review+) e monta UM contexto único com todas
353
+ // as pendentes em ordem topológica — spec-kit acionado uma vez.
354
+ async function implementFeature({ token, owner, repo, config, feature, featureDirOpt, dryRun }) {
355
+ // F1. Stories (sub-issues) da Feature.
356
+ const subs = await listSubIssues(token, feature.node_id).catch(() => []);
357
+ const stories = subs.filter(s => detectIssueType({ title: s.title, labels: s.labels }) === 'Story');
358
+ if (stories.length === 0) {
359
+ p.log.error(
360
+ `Feature #${feature.number} não tem Stories (sub-issues). ` +
361
+ 'Decomponha primeiro: adicione a label spec-wave:decompose.'
362
+ );
363
+ process.exitCode = 1;
364
+ return;
365
+ }
366
+ p.log.info(`Feature com ${stories.length} story(ies): ${stories.map(s => `#${s.number}`).join(', ')}`);
367
+
368
+ // F2. Dependências de cada Story: linha "Depende de:" do body ∪ blocked_by nativo.
369
+ const enriched = await Promise.all(stories.map(async (s) => {
370
+ let body = s.body;
371
+ if (!body) body = (await getIssue(token, owner, repo, s.number).catch(() => null))?.body || '';
372
+ const deps = new Set(parseDependencies(body));
373
+ const blocked = await listBlockedBy(token, owner, repo, s.number).catch(() => []);
374
+ for (const b of blocked) deps.add(b.number);
375
+ return { number: s.number, title: s.title, nodeId: s.nodeId, body: body || '', dependsOn: [...deps] };
376
+ }));
377
+
378
+ // F3. Etapa de cada Story no board (best-effort — sem board, nada é pulado).
379
+ const { project, error: projectError } = loadProjectConfig();
380
+ const stageOf = new Map();
381
+ if (projectError) {
382
+ p.log.warn(`${projectError} — Etapas do board não consultadas; nenhuma Story será considerada implementada.`);
383
+ } else {
384
+ const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
385
+ if (etapaField?.id) {
386
+ await Promise.all(enriched.map(async (s) => {
387
+ try {
388
+ const itemId = await addProjectItem(token, project.id, s.nodeId);
389
+ stageOf.set(s.number, await getItemSingleSelectValue(token, itemId, etapaField.id));
390
+ } catch {
391
+ stageOf.set(s.number, null);
392
+ }
393
+ }));
394
+ }
395
+ }
396
+
397
+ // F4. Planejamento: pendentes em ordem topológica, puladas, ciclos.
398
+ const { pending, skipped, cycle } = planFeatureImplementation(
399
+ enriched.map(s => ({ ...s, stage: stageOf.get(s.number) ?? null }))
400
+ );
401
+ if (cycle.length > 0) {
402
+ p.log.error(
403
+ `Ciclo de dependências entre Stories pendentes: ${cycle.map(n => `#${n}`).join(', ')}. ` +
404
+ 'Corrija as linhas "Depende de:" (ou as relações blocked by) dessas Stories — ' +
405
+ `use \`spec-wave order ${feature.number}\` para visualizar.`
406
+ );
407
+ process.exitCode = 1;
408
+ return;
409
+ }
410
+ if (skipped.length > 0) {
411
+ p.log.info(
412
+ `Puladas (já em ${STAGE_CODE_REVIEW}+): ` +
413
+ skipped.map(s => `#${s.number} (${s.stage})`).join(', ')
414
+ );
415
+ }
416
+ if (pending.length === 0) {
417
+ p.outro(`Todas as ${stories.length} story(ies) da Feature #${feature.number} já estão implementadas — nada a fazer.`);
418
+ return;
419
+ }
420
+ p.log.info(`Ordem de implementação: ${pending.map(s => `#${s.number}`).join(' → ')}`);
421
+
422
+ // F5. Tasks de cada Story pendente.
423
+ const noTasks = [];
424
+ for (const s of pending) {
425
+ const storySubs = await listSubIssues(token, s.nodeId).catch(() => []);
426
+ s.tasks = storySubs
427
+ .filter(t => detectIssueType({ title: t.title, labels: t.labels }) === 'Task')
428
+ .map(t => ({ number: t.number, title: t.title, body: t.body || '' }));
429
+ if (s.tasks.length === 0) noTasks.push(s.number);
430
+ }
431
+ if (noTasks.length > 0) {
432
+ p.log.error(
433
+ `Story(ies) pendente(s) sem Tasks (sub-issues): ${noTasks.map(n => `#${n}`).join(', ')}. ` +
434
+ 'Decomponha-as antes de implementar (re-rode o decompose da Feature se necessário).'
435
+ );
436
+ process.exitCode = 1;
437
+ return;
438
+ }
439
+
440
+ // F6. spec.md/plan.md — a issue-alvo JÁ é a Feature (sem resolveFeature).
441
+ const featureDir = featureDirOpt || path.join('docs', 'features', slugify(feature.title));
442
+ let specPlan = { spec: null, plan: null, specPath: null, planPath: null };
443
+ if (existsSync(featureDir)) {
444
+ specPlan = readSpecPlan(featureDir);
445
+ } else {
446
+ p.log.warn(`Diretório da feature não encontrado (${featureDir}); seguindo só com as Stories.`);
447
+ }
448
+
449
+ // F7. Dependências EXTERNAS ainda abertas (da Feature e das Stories pendentes)
450
+ // — as internas ao conjunto de Stories já estão cobertas pela ordem topológica.
451
+ const blockedByWarnings = [];
452
+ try {
453
+ const internal = new Set(stories.map(s => s.number));
454
+ const featureDeps = new Set(parseDependencies(feature.body));
455
+ const featureBlocked = await listBlockedBy(token, owner, repo, feature.number).catch(() => []);
456
+ for (const b of featureBlocked) featureDeps.add(b.number);
457
+ const check = [
458
+ { kind: 'Feature', number: feature.number, deps: featureDeps },
459
+ ...pending.map(s => ({ kind: 'Story', number: s.number, deps: new Set(s.dependsOn) })),
460
+ ];
461
+ for (const c of check) {
462
+ for (const depNumber of [...c.deps].sort((a, b) => a - b)) {
463
+ if (internal.has(depNumber)) continue;
464
+ const dep = await getIssue(token, owner, repo, depNumber).catch(() => null);
465
+ if (!dep || dep.state === 'closed') continue;
466
+ const warning =
467
+ `${c.kind} #${c.number} depende de #${depNumber} («${dep.title}»), ` +
468
+ `que ainda não está concluída (state: ${dep.state}).`;
469
+ blockedByWarnings.push(warning);
470
+ p.log.warn(warning);
471
+ }
472
+ }
473
+ } catch { /* aviso é best-effort — segue sem ele */ }
474
+
475
+ // F8. Comentários: Feature primeiro, depois cada Story pendente na ordem.
476
+ const comments = [];
477
+ const commentSources = [
478
+ { number: feature.number, kind: 'Feature' },
479
+ ...pending.map(s => ({ number: s.number, kind: 'Story' })),
480
+ ];
481
+ for (const src of commentSources) {
482
+ const all = await listIssueComments(token, owner, repo, src.number).catch(() => []);
483
+ if (all.length === 0) continue;
484
+ const items = all.slice(-MAX_COMMENTS_PER_ISSUE).map(c => ({
485
+ ...c,
486
+ body: c.body.length > MAX_COMMENT_CHARS
487
+ ? `${c.body.slice(0, MAX_COMMENT_CHARS)}…[truncado]`
488
+ : c.body,
489
+ }));
490
+ comments.push({ issueNumber: src.number, kind: src.kind, total: all.length, items });
491
+ }
492
+
493
+ // F9. Digest do estado do código desde a criação da Feature.
494
+ let codeDigest = null;
495
+ try {
496
+ const paths = specPlan.plan ? extractPathsFromPlan(specPlan.plan) : [];
497
+ codeDigest = await buildCodeDigest({ sinceIso: feature.created_at || null, paths });
498
+ } catch {
499
+ codeDigest = null;
500
+ }
501
+
502
+ // F10. Contexto único + spec-kit (uma execução).
503
+ const context = buildFeatureContext({
504
+ feature: { number: feature.number, title: feature.title, body: feature.body || '' },
505
+ stories: pending,
506
+ skipped,
507
+ ...specPlan,
508
+ comments,
509
+ codeDigest,
510
+ blockedByWarnings,
511
+ });
512
+ writeContextAndRunSpecKit({
513
+ config,
514
+ issueNumber: feature.number,
515
+ type: 'Feature',
516
+ title: feature.title,
517
+ specPlan,
518
+ context,
519
+ dryRun,
520
+ outroSuccess:
521
+ `${chalk.green('✓')} Implementação acionada para a Feature #${feature.number} ` +
522
+ `(${pending.length} story(ies) pendente(s)).\n` +
523
+ ` Próximo: acompanhe os PRs de cada Story; a Feature avança para ${STAGE_CODE_REVIEW} após a última.`,
524
+ });
525
+ }
526
+
197
527
  export async function implement({ issue: issueArg, featureDir: featureDirOpt, dryRun }) {
198
528
  const issueNumber = parseInt(String(issueArg).replace('#', ''), 10);
199
529
  if (!Number.isInteger(issueNumber)) {
@@ -260,9 +590,13 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
260
590
  } else if (type === 'Task') {
261
591
  tasks = [{ number: issue.number, title: issue.title, body: issue.body || '' }];
262
592
  p.log.info(`Task única #${issueNumber}.`);
593
+ } else if (type === 'Feature') {
594
+ // Modo Feature: Stories pendentes em ordem de dependência, contexto único.
595
+ await implementFeature({ token, owner, repo, config, feature: issue, featureDirOpt, dryRun });
596
+ return;
263
597
  } else {
264
598
  p.log.error(
265
- `implement só aceita Story ou Task. Issue #${issueNumber} é do tipo ${type || 'desconhecido'}.`
599
+ `implement só aceita Feature, Story ou Task. Issue #${issueNumber} é do tipo ${type || 'desconhecido'}.`
266
600
  );
267
601
  process.exitCode = 1;
268
602
  return;
@@ -298,8 +632,6 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
298
632
  // 4c. Comentários das issues (best-effort) — revisões e correções vivem nos
299
633
  // comentários, não no body; sem eles o agente implementa instruções já
300
634
  // corrigidas. Feature primeiro (correções de escopo), depois a issue-alvo.
301
- const MAX_COMMENTS_PER_ISSUE = 15;
302
- const MAX_COMMENT_CHARS = 2000;
303
635
  const comments = [];
304
636
  const commentSources = [];
305
637
  if (feature && feature.number !== issue.number) {
@@ -353,17 +685,27 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
353
685
  }
354
686
  } catch { /* aviso é best-effort — segue sem ele */ }
355
687
 
356
- // 5. Monta e grava o arquivo de contexto.
688
+ // 5-6. Monta o contexto e aciona o spec-kit (comando configurável).
357
689
  const context = buildContext({
358
690
  type, issue, tasks, feature, siblingStories, ...specPlan,
359
691
  comments, codeDigest, blockedByWarnings,
360
692
  });
693
+ writeContextAndRunSpecKit({
694
+ config, issueNumber, type, title: issue.title, specPlan, context, dryRun,
695
+ outroSuccess:
696
+ `${chalk.green('✓')} Implementação acionada para ${type} #${issueNumber}.\n` +
697
+ ' Próximo: revise as mudanças, abra o PR e mova o card para 👀 Code Review.',
698
+ });
699
+ }
700
+
701
+ // Grava o arquivo de contexto e aciona o spec-kit (comando configurável) —
702
+ // fecho comum dos modos Feature e Story/Task.
703
+ function writeContextAndRunSpecKit({ config, issueNumber, type, title, specPlan, context, dryRun, outroSuccess }) {
361
704
  mkdirSync(WORK_DIR, { recursive: true });
362
705
  const tasksFile = path.join(WORK_DIR, `implement-${issueNumber}.md`);
363
706
  writeFileSync(tasksFile, context);
364
707
  p.log.success(`Contexto montado em ${chalk.cyan(tasksFile)}.`);
365
708
 
366
- // 6. Aciona o spec-kit (comando configurável).
367
709
  const template = process.env.SPEC_WAVE_IMPLEMENT_CMD || config.specKit?.command;
368
710
  const vars = {
369
711
  tasksFile,
@@ -371,7 +713,7 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
371
713
  planFile: specPlan.planPath || '',
372
714
  issue: String(issueNumber),
373
715
  type,
374
- title: issue.title,
716
+ title,
375
717
  };
376
718
 
377
719
  if (!template) {
@@ -404,8 +746,5 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
404
746
  return;
405
747
  }
406
748
 
407
- p.outro(
408
- `${chalk.green('✓')} Implementação acionada para ${type} #${issueNumber}.\n` +
409
- ' Próximo: revise as mudanças, abra o PR e mova o card para 👀 Code Review.'
410
- );
749
+ p.outro(outroSuccess);
411
750
  }
@@ -86,7 +86,7 @@ Labels de **estado** (gravadas pelas automações — **não** são gatilhos, n
86
86
  - `spec-wave:critique-failed` → a crítica adversarial apontou contradições **graves** nos documentos; **bloqueia** o `spec-wave:ready` até ser removida (veja *Crítica adversarial* abaixo)
87
87
  - `spec-wave:decomposed` → a Feature/RFC já foi decomposta; o `decompose` pula silenciosamente enquanto ela existir (veja *Guard de idempotência* abaixo)
88
88
 
89
- A etapa **🚧 Desenvolvimento** é coberta pelo comando **local** `npx @spec-wave/cli implement <número>` (não é uma label/Action): lê uma Story ou Task e aciona o spec-kit para implementar. Veja `/spec-wave implement`.
89
+ A etapa **🚧 Desenvolvimento** é coberta pelo comando **local** `npx @spec-wave/cli implement <número>` (não é uma label/Action): lê uma **Feature** (todas as Stories pendentes, em ordem de dependência), uma Story ou uma Task e aciona o spec-kit para implementar. Veja `/spec-wave implement`.
90
90
 
91
91
  ---
92
92
 
@@ -169,14 +169,14 @@ Mesmas flags do `issue` (exceto `--type`, fixo em `feature`). Mantido para o flu
169
169
  > - `generate-spec` / `generate-plan` → **apenas Features**. Para **Spike, RFC e Bug** a geração é **pulada** (o Action remove a label e comenta) — esses tipos não usam spec/plan.
170
170
  > - `decompose` → **Feature** (gera Stories + Tasks) e **RFC** (gera **Tasks** diretamente, sem Stories). Para outros tipos, o Action recusa.
171
171
 
172
- ### `@spec-wave/cli implement` — aciona o spec-kit para uma Story ou Task (comando LOCAL)
172
+ ### `@spec-wave/cli implement` — aciona o spec-kit para uma Feature, Story ou Task (comando LOCAL)
173
173
  | Flag/Arg | Tipo | Descrição |
174
174
  |----------|------|-----------|
175
- | `<issue>` | string (obrigatório) | Número da issue (Story ou Task), ex.: `12` ou `#12`. Argumento posicional. |
175
+ | `<issue>` | string (obrigatório) | Número da issue (Feature, Story ou Task), ex.: `12` ou `#12`. Argumento posicional. |
176
176
  | `--feature-dir <path>` | string | Caminho `docs/features/<slug>` para anexar `spec.md`/`plan.md` como contexto (sobrescreve a resolução automática). |
177
177
  | `--dry-run` | flag | Monta o contexto e imprime o comando do spec-kit **sem executar**. |
178
178
 
179
- > Diferente dos quatro acima, `implement` roda **localmente** (lê `.spec-wave.json`, como `issue`), não por Action. Detecta o tipo da issue: **Story** → coleta todas as Tasks (sub-issues) e aciona o spec-kit uma única vez; **Task** → só aquela task. Monta o contexto em `.spec-wave/implement-<n>.md` e chama o comando configurado em `specKit.command` (no `.spec-wave.json`) ou na env `SPEC_WAVE_IMPLEMENT_CMD`. Placeholders disponíveis no template: `{tasksFile} {specFile} {planFile} {issue} {type} {title}`. Se nada estiver configurado, ele apenas monta o contexto e mostra como configurar (não executa). O contexto inclui os **comentários da issue**, um **digest do código recente** e um **aviso de dependências pendentes** quando a issue depende (linha `Depende de: #N` ou relação nativa *blocked by*) de outra que ainda não foi concluída — nesse caso, confirme com o usuário antes de seguir. Inclui também instruções para o agente implementar as Tasks **sequencialmente, uma por vez** (nunca duas com Status "In Progress" ao mesmo tempo): cada Task usa o **Status** (In Progress) *dentro* da Etapa 🚧 Desenvolvimento e, **ao concluir, avança para a Etapa 🎉 Done com Status Done**. **Ao concluir toda a Story**: fazer o commit, abrir o PR e **avançar a Etapa da Story para 👀 Code Review** (Status → Todo) — as Tasks já estão em 🎉 Done. A **Feature só avança** para Code Review quando **TODAS as suas Stories** já estiverem em Code Review — enquanto houver Story pendente, a Feature fica em 🚧 Desenvolvimento. Etapa só avança (nunca volta); Status mede o progresso dentro da etapa.
179
+ > Diferente dos quatro acima, `implement` roda **localmente** (lê `.spec-wave.json`, como `issue`), não por Action. Detecta o tipo da issue: **Feature** → lista as Stories (sub-issues), **ordena topologicamente pelas dependências** (`Depende de:` + *blocked by*), **pula** as já em 👀 Code Review+ (listadas no contexto como "não tocar") e monta **um único** contexto com todas as pendentes (cada uma com suas Tasks), acionando o spec-kit **uma vez** com `{issue}/{type}/{title}` da Feature — **ciclo de dependências entre Stories pendentes aborta o comando (exit 1)**; **Story** → coleta todas as Tasks (sub-issues) e aciona o spec-kit uma única vez; **Task** → só aquela task. Monta o contexto em `.spec-wave/implement-<n>.md` e chama o comando configurado em `specKit.command` (no `.spec-wave.json`) ou na env `SPEC_WAVE_IMPLEMENT_CMD`. Placeholders disponíveis no template: `{tasksFile} {specFile} {planFile} {issue} {type} {title}`. Se nada estiver configurado, ele apenas monta o contexto e mostra como configurar (não executa). O contexto inclui os **comentários da issue**, um **digest do código recente** e um **aviso de dependências pendentes** quando a issue depende (linha `Depende de: #N` ou relação nativa *blocked by*) de outra que ainda não foi concluída — nesse caso, confirme com o usuário antes de seguir. Inclui também instruções para o agente implementar as Tasks **sequencialmente, uma por vez** (nunca duas com Status "In Progress" ao mesmo tempo): cada Task usa o **Status** (In Progress) *dentro* da Etapa 🚧 Desenvolvimento e, **ao concluir, avança para a Etapa 🎉 Done com Status Done**. **Ao concluir toda a Story**: fazer o commit, abrir o PR e **avançar a Etapa da Story para 👀 Code Review** (Status → Todo) — as Tasks já estão em 🎉 Done. A **Feature só avança** para Code Review quando **TODAS as suas Stories** já estiverem em Code Review — enquanto houver Story pendente, a Feature fica em 🚧 Desenvolvimento. Etapa só avança (nunca volta); Status mede o progresso dentro da etapa.
180
180
 
181
181
  ### `@spec-wave/cli doctor` — preflight de auth e configuração (comando LOCAL)
182
182
  Sem flags. Roda um checklist de diagnóstico no repositório atual: token GitHub (e a fonte dele), escopos (`repo`, `project`, `workflow` — com degradação para checks funcionais em fine-grained PATs), conta ativa do `gh` vs. owner, `.spec-wave.json` (campos e sincronia com o Project real), acesso ao repositório, configuração de IA (provider/modelo/`ai.models` + secrets do Actions), **spec-kit** (`specKit.command` / env `SPEC_WAVE_IMPLEMENT_CMD` — se ausente, avisa e sugere exemplos por agente: Claude Code, opencode, Codex, Copilot CLI, Kiro CLI, Qwen Code) e presença dos workflows.
@@ -228,7 +228,8 @@ O evento `labeled` pode redisparar (re-add da label, retry de runner). Para não
228
228
 
229
229
  O `decompose` grava nas Stories geradas uma linha **`Depende de: #N, #M`** no corpo e cria a relação nativa *blocked by* do GitHub. Essas dependências alimentam:
230
230
  - `spec-wave order <feature>` → ordem topológica de execução;
231
- - `spec-wave implement <n>` → **aviso** no contexto quando uma dependência ainda não está concluída (confirme com o usuário antes de implementar fora de ordem).
231
+ - `spec-wave implement <feature>` → Stories pendentes implementadas **nessa ordem**; **ciclo de dependências erro** (corrija as linhas `Depende de:`); dependências **externas** abertas viram aviso no contexto;
232
+ - `spec-wave implement <n>` (Story/Task) → **aviso** no contexto quando uma dependência ainda não está concluída (confirme com o usuário antes de implementar fora de ordem).
232
233
 
233
234
  Não apague a linha `Depende de:` ao editar o corpo de uma Story; para mudar dependências, edite a linha (e/ou a relação *blocked by*).
234
235
 
@@ -480,13 +481,15 @@ Para qualquer outro tipo (Spike, Bug, Story, Task, …) o Action **recusa** e co
480
481
 
481
482
  ### `/spec-wave implement <número-da-issue>`
482
483
 
483
- Aciona o spec-kit para implementar uma **Story** (todas as suas Tasks) ou uma **Task** isolada. Comando **local** (etapa 🚧 Desenvolvimento) — não usa label/Action.
484
+ Aciona o spec-kit para implementar uma **Feature** (todas as Stories pendentes, em ordem de dependência), uma **Story** (todas as suas Tasks) ou uma **Task** isolada. Comando **local** (etapa 🚧 Desenvolvimento) — não usa label/Action.
484
485
 
485
- **Pré-requisitos:** o repositório atual precisa estar inicializado (`.spec-wave.json` presente) e a issue deve ser do tipo Story ou Task. Para executar de fato (fora do `--dry-run`), o spec-kit precisa estar configurado via `specKit.command` no `.spec-wave.json` ou a env `SPEC_WAVE_IMPLEMENT_CMD`.
486
+ **Pré-requisitos:** o repositório atual precisa estar inicializado (`.spec-wave.json` presente) e a issue deve ser do tipo Feature, Story ou Task. Para executar de fato (fora do `--dry-run`), o spec-kit precisa estar configurado via `specKit.command` no `.spec-wave.json` ou a env `SPEC_WAVE_IMPLEMENT_CMD`.
487
+
488
+ **Modo Feature:** o comando avalia as Stories da Feature — ordena topologicamente pelas dependências (`Depende de:` + *blocked by*), consulta a Etapa de cada uma no board e **pula as já implementadas** (👀 Code Review ou além). O contexto único (`.spec-wave/implement-<feature>.md`) traz as pendentes em ordem, cada uma com suas Tasks. **Ciclo de dependências entre Stories pendentes → o comando aborta** (corrija as linhas `Depende de:`; use `npx @spec-wave/cli order <feature>` para visualizar). Story pendente sem Tasks → aborta pedindo decomposição. Todas implementadas → encerra sem acionar o spec-kit.
486
489
 
487
490
  **Passos:**
488
491
  1. Confirme que há `.spec-wave.json` no repo (senão, oriente `/spec-wave setup`).
489
- 2. **Sempre comece com `--dry-run`** para inspecionar o que será feito — detecção do tipo, lista de Tasks coletadas (no caso de Story) e o comando do spec-kit que seria executado:
492
+ 2. **Sempre comece com `--dry-run`** para inspecionar o que será feito — detecção do tipo, lista de Tasks coletadas (Story) ou a ordem/puladas/ciclos das Stories (Feature) e o comando do spec-kit que seria executado:
490
493
  ```bash
491
494
  npx @spec-wave/cli implement <número> --dry-run
492
495
  ```
@@ -498,8 +501,8 @@ Aciona o spec-kit para implementar uma **Story** (todas as suas Tasks) ou uma **
498
501
  ```
499
502
  - Se o spec-kit **não** estiver configurado, o comando só monta o contexto e mostra como configurar (`specKit.command` / `SPEC_WAVE_IMPLEMENT_CMD`). Ajude o usuário a definir o template (placeholders: `{tasksFile} {specFile} {planFile} {issue} {type} {title}`).
500
503
  - Use `--feature-dir docs/features/<slug>` se a resolução automática da Feature falhar (a skill avisa com warning) e você quiser anexar `spec.md`/`plan.md` como contexto.
501
- 6. Se a issue **não** for Story nem Task (ex.: Feature, Bug), o comando recusa oriente o usuário: Features se decompõem (`/spec-wave decompose`); implemente as Stories/Tasks resultantes.
502
- 7. Ao final (Tasks em **🎉 Done**, Story em **👀 Code Review**; a Feature só vai para Code Review quando a última Story concluir): confirme o resultado com o usuário e oriente a revisão do PR.
504
+ 6. **No modo Feature**, siga o contexto Story a Story, na ordem listada: para cada Story pendente, implemente as Tasks com `task start`/`task done`, depois commit + PR + `npx @spec-wave/cli story review <n>`; só então passe à próxima Story. Se a issue **não** for Feature, Story nem Task (ex.: Bug, Spike, Epic), o comando recusa. Feature **sem Stories** rode `/spec-wave decompose` primeiro. **Ciclo de dependências** → corrija as linhas `Depende de:` (veja `spec-wave order`).
505
+ 7. Ao final (Tasks em **🎉 Done**, Story em **👀 Code Review**; a Feature só vai para Code Review quando a última Story concluir — no modo Feature, isso acontece dentro da mesma execução): confirme o resultado com o usuário e oriente a revisão dos PRs.
503
506
 
504
507
  ---
505
508