@spec-wave/cli 0.5.9 → 0.5.10
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 +14 -0
- package/package.json +1 -1
- package/src/api/github-graphql.mjs +25 -0
- package/src/api/github-rest.mjs +25 -0
- package/src/commands/code-review.mjs +75 -15
- package/src/commands/implement.mjs +38 -24
- package/src/commands/install-skill.mjs +22 -9
- package/src/commands/qa.mjs +19 -6
- package/src/commands/update.mjs +300 -0
- package/src/config.mjs +15 -6
- package/src/templates/skill/SKILL.md +35 -5
package/bin/spec-wave.mjs
CHANGED
|
@@ -86,6 +86,20 @@ program
|
|
|
86
86
|
await feature(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
program
|
|
90
|
+
.command('update')
|
|
91
|
+
.description('Detecta o que está desatualizado (skill, .spec-wave.json, workflows/labels do repo) e atualiza só o que mudou')
|
|
92
|
+
.option('--global', 'Verifica a skill no escopo do usuário (padrão: projeto)')
|
|
93
|
+
.option('--skip-skill', 'Não verifica/atualiza a skill instalada')
|
|
94
|
+
.option('--skip-config', 'Não verifica/atualiza o .spec-wave.json local')
|
|
95
|
+
.option('--skip-repo', 'Não verifica/atualiza workflows e labels do repo')
|
|
96
|
+
.option('--dry-run', 'Mostra o que seria atualizado sem alterar nada')
|
|
97
|
+
.option('--yes', 'Aplica sem pedir confirmação')
|
|
98
|
+
.action(async (options) => {
|
|
99
|
+
const { update } = await import('../src/commands/update.mjs');
|
|
100
|
+
await update(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
101
|
+
});
|
|
102
|
+
|
|
89
103
|
program
|
|
90
104
|
.command('install-skill')
|
|
91
105
|
.description('Instala a skill spec-wave no(s) agente(s) detectado(s): Claude Code, Cursor, opencode, Cline, Kilo, Antigravity, AGENTS.md')
|
package/package.json
CHANGED
|
@@ -233,6 +233,31 @@ export async function getIssueParent(token, issueNodeId) {
|
|
|
233
233
|
return { number: parent.number, title: parent.title, nodeId: parent.id };
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
// Lê o valor atual (nome da opção) de um campo SINGLE_SELECT para um item do
|
|
237
|
+
// Project. Usado para garantir que a Etapa só avança (nunca retrocede).
|
|
238
|
+
export async function getItemSingleSelectValue(token, itemId, fieldId) {
|
|
239
|
+
const client = makeClient(token);
|
|
240
|
+
const result = await client(`
|
|
241
|
+
query ItemValue($itemId: ID!) {
|
|
242
|
+
node(id: $itemId) {
|
|
243
|
+
... on ProjectV2Item {
|
|
244
|
+
fieldValues(first: 50) {
|
|
245
|
+
nodes {
|
|
246
|
+
... on ProjectV2ItemFieldSingleSelectValue {
|
|
247
|
+
name
|
|
248
|
+
field { ... on ProjectV2SingleSelectField { id } }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
`, { itemId });
|
|
256
|
+
const nodes = result.node?.fieldValues?.nodes || [];
|
|
257
|
+
const value = nodes.find(n => n && n.field?.id === fieldId);
|
|
258
|
+
return value?.name ?? null;
|
|
259
|
+
}
|
|
260
|
+
|
|
236
261
|
// Define o valor de um campo SINGLE_SELECT para um item do Project.
|
|
237
262
|
export async function setItemSingleSelect(token, projectId, itemId, fieldId, optionId) {
|
|
238
263
|
const client = makeClient(token);
|
package/src/api/github-rest.mjs
CHANGED
|
@@ -43,6 +43,31 @@ export async function createLabel(token, owner, repo, label) {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// Lista todas as labels do repo: [{ name, color, description }]. color é hex
|
|
47
|
+
// sem "#" (minúsculo, como a API retorna). Usado pelo `update` para detectar
|
|
48
|
+
// labels ausentes ou com cor/descrição divergente.
|
|
49
|
+
export async function listLabels(token, owner, repo) {
|
|
50
|
+
const octokit = makeOctokit(token);
|
|
51
|
+
const labels = await octokit.paginate(octokit.rest.issues.listLabelsForRepo, {
|
|
52
|
+
owner,
|
|
53
|
+
repo,
|
|
54
|
+
per_page: 100,
|
|
55
|
+
});
|
|
56
|
+
return labels.map(l => ({ name: l.name, color: l.color, description: l.description || '' }));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Atualiza cor/descrição de uma label existente (mantém o nome).
|
|
60
|
+
export async function updateLabel(token, owner, repo, label) {
|
|
61
|
+
const octokit = makeOctokit(token);
|
|
62
|
+
await octokit.rest.issues.updateLabel({
|
|
63
|
+
owner,
|
|
64
|
+
repo,
|
|
65
|
+
name: label.name,
|
|
66
|
+
color: label.color,
|
|
67
|
+
description: label.description,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
46
71
|
export async function upsertFile(token, owner, repo, path, content, message) {
|
|
47
72
|
const octokit = makeOctokit(token);
|
|
48
73
|
let sha;
|
|
@@ -2,13 +2,13 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { resolveToken } from '../api/auth.mjs';
|
|
4
4
|
import { getIssue, getPR, commentOnIssue } from '../api/github-rest.mjs';
|
|
5
|
-
import { addProjectItem, setItemSingleSelect, getSingleSelectField, getIssueParent } from '../api/github-graphql.mjs';
|
|
5
|
+
import { addProjectItem, setItemSingleSelect, getSingleSelectField, getIssueParent, listSubIssues, getItemSingleSelectValue } from '../api/github-graphql.mjs';
|
|
6
6
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
7
|
-
import { CONFIG_FILE, STATUS_OPTIONS } from '../config.mjs';
|
|
7
|
+
import { CONFIG_FILE, STATUS_OPTIONS, STAGE_ORDER, PROGRESS_TODO } from '../config.mjs';
|
|
8
8
|
|
|
9
9
|
// Campo "Etapa" (custom) → "👀 Code Review". Campo "Status" (nativo) → "Todo".
|
|
10
10
|
const CODE_REVIEW_STAGE = STATUS_OPTIONS.find(s => s.name.includes('Code Review'))?.name;
|
|
11
|
-
const TODO_STATUS =
|
|
11
|
+
const TODO_STATUS = PROGRESS_TODO;
|
|
12
12
|
|
|
13
13
|
// Extrai números de issues referenciadas no corpo do PR (Closes #N, Fixes #N, #N solto).
|
|
14
14
|
function extractIssueNumbers(body) {
|
|
@@ -54,17 +54,69 @@ async function resolveFeatureIssue(token, owner, repo, issueNumber) {
|
|
|
54
54
|
return null;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
//
|
|
57
|
+
// Coleta a "unidade de review" de uma issue referenciada no PR: a Feature
|
|
58
|
+
// ancestral + a Story + as Tasks dessa Story — tudo anda junto com a Feature.
|
|
59
|
+
// Retorna Map number -> { nodeId, title }.
|
|
60
|
+
async function collectReviewUnit(token, owner, repo, issueNumber) {
|
|
61
|
+
const unit = new Map();
|
|
62
|
+
const add = (n, nodeId, title) => { if (n && nodeId && !unit.has(n)) unit.set(n, { nodeId, title }); };
|
|
63
|
+
|
|
64
|
+
let issue;
|
|
65
|
+
try { issue = await getIssue(token, owner, repo, issueNumber); } catch { return unit; }
|
|
66
|
+
const type = detectIssueType(issue);
|
|
67
|
+
if (type !== 'Feature' && type !== 'Story' && type !== 'Task') return unit;
|
|
68
|
+
|
|
69
|
+
// Feature ancestral (ou a própria).
|
|
70
|
+
const feature = await resolveFeatureIssue(token, owner, repo, issueNumber);
|
|
71
|
+
if (feature) add(feature.number, feature.node_id, feature.title);
|
|
72
|
+
|
|
73
|
+
if (type === 'Feature') {
|
|
74
|
+
// Toda a subárvore da Feature: Stories + Tasks.
|
|
75
|
+
const stories = await listSubIssues(token, issue.node_id).catch(() => []);
|
|
76
|
+
for (const st of stories) {
|
|
77
|
+
add(st.number, st.nodeId, st.title);
|
|
78
|
+
const tasks = await listSubIssues(token, st.nodeId).catch(() => []);
|
|
79
|
+
for (const t of tasks) add(t.number, t.nodeId, t.title);
|
|
80
|
+
}
|
|
81
|
+
} else if (type === 'Story') {
|
|
82
|
+
add(issue.number, issue.node_id, issue.title);
|
|
83
|
+
const tasks = await listSubIssues(token, issue.node_id).catch(() => []);
|
|
84
|
+
for (const t of tasks) add(t.number, t.nodeId, t.title);
|
|
85
|
+
} else { // Task → inclui a Story pai e as Tasks irmãs.
|
|
86
|
+
add(issue.number, issue.node_id, issue.title);
|
|
87
|
+
const parent = await getIssueParent(token, issue.node_id).catch(() => null);
|
|
88
|
+
if (parent && detectIssueType({ title: parent.title }) === 'Story') {
|
|
89
|
+
add(parent.number, parent.nodeId, parent.title);
|
|
90
|
+
const tasks = await listSubIssues(token, parent.nodeId).catch(() => []);
|
|
91
|
+
for (const t of tasks) add(t.number, t.nodeId, t.title);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return unit;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Avança um item do board para a Etapa "👀 Code Review" e reinicia o Status
|
|
98
|
+
// (nativo) para "Todo". Uma issue só AVANÇA: se já estiver em Code Review ou em
|
|
99
|
+
// uma etapa posterior, não é tocada (retorna false). Retorna true se avançou.
|
|
58
100
|
async function setCodeReview(token, project, etapaField, statusField, nodeId) {
|
|
59
101
|
const itemId = await addProjectItem(token, project.id, nodeId);
|
|
102
|
+
|
|
60
103
|
if (etapaField?.id && CODE_REVIEW_STAGE) {
|
|
104
|
+
// Nunca retroceder: compara a etapa atual com a de destino na ordem canônica.
|
|
105
|
+
const current = await getItemSingleSelectValue(token, itemId, etapaField.id).catch(() => null);
|
|
106
|
+
const curIdx = current ? STAGE_ORDER.indexOf(current) : -1;
|
|
107
|
+
const tgtIdx = STAGE_ORDER.indexOf(CODE_REVIEW_STAGE);
|
|
108
|
+
if (curIdx !== -1 && tgtIdx !== -1 && curIdx >= tgtIdx) {
|
|
109
|
+
return false; // já está em Code Review ou adiante — não retrocede
|
|
110
|
+
}
|
|
61
111
|
const optionId = etapaField.options?.[CODE_REVIEW_STAGE];
|
|
62
112
|
if (optionId) await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
|
|
63
113
|
}
|
|
114
|
+
// Ao avançar de etapa, o Status reinicia em "Todo".
|
|
64
115
|
if (statusField?.id) {
|
|
65
116
|
const optionId = statusField.options?.[TODO_STATUS];
|
|
66
117
|
if (optionId) await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
|
|
67
118
|
}
|
|
119
|
+
return true;
|
|
68
120
|
}
|
|
69
121
|
|
|
70
122
|
export async function codeReview({ prNumber }) {
|
|
@@ -117,16 +169,24 @@ export async function codeReview({ prNumber }) {
|
|
|
117
169
|
const seen = new Set();
|
|
118
170
|
const updated = [];
|
|
119
171
|
|
|
172
|
+
// Para cada issue referenciada, move a unidade inteira (Feature + Story +
|
|
173
|
+
// Tasks) para Code Review — tudo anda junto com a Feature.
|
|
120
174
|
for (const num of issueNums) {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
175
|
+
const unit = await collectReviewUnit(token, owner, repo, num);
|
|
176
|
+
for (const [n, info] of unit) {
|
|
177
|
+
if (seen.has(n)) continue;
|
|
178
|
+
seen.add(n);
|
|
179
|
+
try {
|
|
180
|
+
const moved = await setCodeReview(projectToken, project, etapaField, statusField, info.nodeId);
|
|
181
|
+
if (moved) {
|
|
182
|
+
updated.push(`#${n} ${info.title}`);
|
|
183
|
+
console.log(`#${n} → "${CODE_REVIEW_STAGE}" / Status "${TODO_STATUS}".`);
|
|
184
|
+
} else {
|
|
185
|
+
console.log(`#${n} já está em "${CODE_REVIEW_STAGE}" ou etapa posterior — mantido (não retrocede).`);
|
|
186
|
+
}
|
|
187
|
+
} catch (err) {
|
|
188
|
+
console.warn(`Falha ao atualizar #${n}: ${err.message}`);
|
|
189
|
+
}
|
|
130
190
|
}
|
|
131
191
|
}
|
|
132
192
|
|
|
@@ -134,10 +194,10 @@ export async function codeReview({ prNumber }) {
|
|
|
134
194
|
await commentOnIssue(
|
|
135
195
|
token, owner, repo, parseInt(prNumber, 10),
|
|
136
196
|
`🔍 **Code Review iniciado**\n\n` +
|
|
137
|
-
`
|
|
197
|
+
`Movidos para **${CODE_REVIEW_STAGE}** (Feature + Story + Tasks):\n\n` +
|
|
138
198
|
updated.map(f => `- ${f}`).join('\n')
|
|
139
199
|
).catch(() => {});
|
|
140
200
|
}
|
|
141
201
|
|
|
142
|
-
console.log(`code-review: ${updated.length}
|
|
202
|
+
console.log(`code-review: ${updated.length} item(ns) movido(s) para "${CODE_REVIEW_STAGE}".`);
|
|
143
203
|
}
|
|
@@ -4,7 +4,10 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
|
4
4
|
import { execSync } from 'node:child_process';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { resolveToken } from '../api/auth.mjs';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
CONFIG_FILE, STAGE_DEVELOPMENT, STAGE_CODE_REVIEW,
|
|
9
|
+
PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE,
|
|
10
|
+
} from '../config.mjs';
|
|
8
11
|
import { getIssue } from '../api/github-rest.mjs';
|
|
9
12
|
import { listSubIssues, getIssueParent } from '../api/github-graphql.mjs';
|
|
10
13
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
@@ -53,46 +56,57 @@ function buildContext({ type, issue, tasks, feature, spec, plan, specPath, planP
|
|
|
53
56
|
lines.push(issue.body.trim());
|
|
54
57
|
}
|
|
55
58
|
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
+
// Modelo do board: "Etapa" (coluna do kanban) = DIREÇÃO, só avança; "Status"
|
|
60
|
+
// (Todo/In Progress/Done) = PROGRESSO dentro da etapa, reinicia a cada avanço.
|
|
61
|
+
// O desenvolvimento acontece na Etapa 🚧 Desenvolvimento; o progresso por task
|
|
62
|
+
// é registrado no STATUS. Ao final, a Etapa avança para Code Review.
|
|
59
63
|
lines.push('');
|
|
60
64
|
lines.push('## Instruções de execução (uma task por vez, sequencial)');
|
|
61
65
|
lines.push('');
|
|
66
|
+
lines.push(
|
|
67
|
+
'Há **dois campos** no board com papéis diferentes — não os confunda:\n' +
|
|
68
|
+
`- **Etapa** (Backlog → … → ${STAGE_DEVELOPMENT} → ${STAGE_CODE_REVIEW} → …): a DIREÇÃO no kanban. Uma issue só **avança**, **nunca** volta para uma etapa anterior.\n` +
|
|
69
|
+
`- **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}**.`
|
|
70
|
+
);
|
|
71
|
+
lines.push('');
|
|
62
72
|
if (type === 'Story') {
|
|
63
73
|
lines.push(
|
|
64
|
-
`
|
|
65
|
-
'
|
|
66
|
-
'só entra em desenvolvimento depois que a anterior estiver concluída.'
|
|
74
|
+
`Nesta fase, a Story #${issue.number} e suas Tasks estão na Etapa **${STAGE_DEVELOPMENT}**. ` +
|
|
75
|
+
'Durante a implementação, mexa **apenas no Status** das Tasks — **não** mude a Etapa delas.'
|
|
67
76
|
);
|
|
68
77
|
lines.push('');
|
|
69
|
-
lines.push(`1.
|
|
70
|
-
lines.push(
|
|
71
|
-
lines.push(` 1. **Ao começar
|
|
72
|
-
lines.push(' 2. **Implemente**
|
|
73
|
-
lines.push(` 3. **Ao concluir
|
|
74
|
-
lines.push(' 4. Só então avance para a próxima
|
|
78
|
+
lines.push(`1. Garanta que a Story #${issue.number} está na Etapa **${STAGE_DEVELOPMENT}** com Status **${PROGRESS_IN_PROGRESS}**, e que as Tasks estão nessa Etapa com Status **${PROGRESS_TODO}**.`);
|
|
79
|
+
lines.push(`2. Implemente as ${tasks.length} task(s) **uma de cada vez, na ordem abaixo**. É PROIBIDO ter mais de uma Task com Status **${PROGRESS_IN_PROGRESS}** ao mesmo tempo. Para **cada Task**, na ordem:`);
|
|
80
|
+
lines.push(` 1. **Ao começar:** Status da Task → **${PROGRESS_IN_PROGRESS}** (a Etapa continua ${STAGE_DEVELOPMENT}).`);
|
|
81
|
+
lines.push(' 2. **Implemente** a Task por completo.');
|
|
82
|
+
lines.push(` 3. **Ao concluir:** Status da Task → **${PROGRESS_DONE}** (ainda na Etapa ${STAGE_DEVELOPMENT}).`);
|
|
83
|
+
lines.push(' 4. Só então avance para a próxima Task.');
|
|
75
84
|
lines.push('');
|
|
76
|
-
lines.push(`3. **Ao concluir
|
|
85
|
+
lines.push(`3. **Ao concluir TODA a Story** (todas as Tasks com Status ${PROGRESS_DONE}):`);
|
|
77
86
|
lines.push(' 1. Faça o **commit** de todas as mudanças da implementação.');
|
|
78
87
|
lines.push(` 2. Abra o **Pull Request** da Story #${issue.number}.`);
|
|
79
88
|
lines.push(
|
|
80
|
-
` 3.
|
|
81
|
-
`Story #${issue.number}
|
|
82
|
-
`
|
|
89
|
+
` 3. **Avance a Etapa — em conjunto — ${feature ? `da Feature #${feature.number}` : 'da Feature (issue pai da Story)'}, ` +
|
|
90
|
+
`a Story #${issue.number} e todas as ${tasks.length} Task(s) (${tasks.map(t => `#${t.number}`).join(', ')}) ` +
|
|
91
|
+
`para ${STAGE_CODE_REVIEW}**, e **reinicie o Status de cada um para ${PROGRESS_TODO}**. ` +
|
|
92
|
+
'Tudo anda junto com a Feature e sempre para frente.'
|
|
83
93
|
);
|
|
84
94
|
} else {
|
|
85
|
-
lines.push(
|
|
95
|
+
lines.push(
|
|
96
|
+
`Esta Task #${issue.number} está na Etapa **${STAGE_DEVELOPMENT}**. Acompanhe o progresso pelo ` +
|
|
97
|
+
'**Status** — **não** mude a Etapa aqui:'
|
|
98
|
+
);
|
|
99
|
+
lines.push('');
|
|
100
|
+
lines.push(`1. **Ao começar:** Status da Task #${issue.number} → **${PROGRESS_IN_PROGRESS}**.`);
|
|
101
|
+
lines.push('2. **Implemente** a Task por completo.');
|
|
102
|
+
lines.push(`3. **Ao concluir:** Status da Task #${issue.number} → **${PROGRESS_DONE}**.`);
|
|
86
103
|
lines.push('');
|
|
87
|
-
lines.push(
|
|
88
|
-
lines.push('2. **Implemente** a task por completo.');
|
|
89
|
-
lines.push(`3. **Ao concluir:** mova o status da Task #${issue.number} para **${STAGE_DONE}** (Done).`);
|
|
104
|
+
lines.push(`> A Etapa avança para **${STAGE_CODE_REVIEW}** (Status reinicia em ${PROGRESS_TODO}) junto com a Story/Feature quando o PR é aberto — nunca volta para uma etapa anterior.`);
|
|
90
105
|
}
|
|
91
106
|
lines.push('');
|
|
92
107
|
lines.push(
|
|
93
|
-
`>
|
|
94
|
-
|
|
95
|
-
'do item no GitHub Project.'
|
|
108
|
+
`> **Regra do board:** a **Etapa** só avança (nunca retrocede); o **Status** (${PROGRESS_TODO}/${PROGRESS_IN_PROGRESS}/${PROGRESS_DONE}) ` +
|
|
109
|
+
'só mede o progresso dentro da etapa atual e reinicia a cada avanço de etapa.'
|
|
96
110
|
);
|
|
97
111
|
|
|
98
112
|
lines.push('');
|
|
@@ -12,16 +12,19 @@ const SKILL_SOURCE = path.join(__dir, '..', 'templates', 'skill', 'SKILL.md');
|
|
|
12
12
|
// Versão da CLI que gerou a skill instalada — carimbada no arquivo para detecção
|
|
13
13
|
// de desatualização (a skill é uma cópia estática; não acompanha o `npx` sozinha).
|
|
14
14
|
const pkg = JSON.parse(readFileSync(path.join(__dir, '..', '..', 'package.json'), 'utf-8'));
|
|
15
|
+
// Reexportados para o comando `update` (detecção de skill desatualizada).
|
|
16
|
+
export { SKILL_SOURCE };
|
|
17
|
+
export const CLI_VERSION = pkg.version;
|
|
15
18
|
|
|
16
19
|
// Marcadores usados para gravar/atualizar a skill de forma idempotente em
|
|
17
20
|
// arquivos compartilhados (AGENTS.md) — permite reinstalar sem duplicar.
|
|
18
|
-
const BLOCK_START = '<!-- spec-wave:start -->';
|
|
19
|
-
const BLOCK_END = '<!-- spec-wave:end -->';
|
|
21
|
+
export const BLOCK_START = '<!-- spec-wave:start -->';
|
|
22
|
+
export const BLOCK_END = '<!-- spec-wave:end -->';
|
|
20
23
|
|
|
21
24
|
// Registro de agentes suportados. Cada alvo descreve como detectá-lo no
|
|
22
25
|
// diretório-base, onde gravar (projeto vs. global) e em que formato converter
|
|
23
26
|
// o SKILL.md. Caminhos conferidos na doc oficial de cada ferramenta.
|
|
24
|
-
const TARGETS = [
|
|
27
|
+
export const TARGETS = [
|
|
25
28
|
{
|
|
26
29
|
key: 'claude',
|
|
27
30
|
name: 'Claude Code',
|
|
@@ -84,7 +87,7 @@ const TARGETS = [
|
|
|
84
87
|
const TARGET_BY_KEY = new Map(TARGETS.map((t) => [t.key, t]));
|
|
85
88
|
|
|
86
89
|
// Separa o frontmatter YAML do corpo do SKILL.md. Retorna { meta, body }.
|
|
87
|
-
function parseSkill(raw) {
|
|
90
|
+
export function parseSkill(raw) {
|
|
88
91
|
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
89
92
|
if (!match) return { meta: {}, frontmatter: '', body: raw.trim() };
|
|
90
93
|
let meta = {};
|
|
@@ -102,12 +105,13 @@ function versionBanner(version) {
|
|
|
102
105
|
return (
|
|
103
106
|
`> ⚙️ **spec-wave skill v${version}** — esta skill é uma cópia estática. ` +
|
|
104
107
|
'Se `npx @spec-wave/cli --version` indicar uma versão maior, ela está ' +
|
|
105
|
-
'desatualizada: rode `npx @spec-wave/cli
|
|
108
|
+
'desatualizada: rode `npx @spec-wave/cli update` (atualiza só o que mudou) ' +
|
|
109
|
+
'ou `npx @spec-wave/cli install-skill --force` (só a skill).'
|
|
106
110
|
);
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
// Converte o SKILL.md para o formato exigido por cada agente, carimbando a versão.
|
|
110
|
-
function renderContent(format, parsed, version) {
|
|
114
|
+
export function renderContent(format, parsed, version) {
|
|
111
115
|
const { meta, frontmatter, body } = parsed;
|
|
112
116
|
const description = meta.description ?? 'Skill spec-wave.';
|
|
113
117
|
const banner = versionBanner(version);
|
|
@@ -135,7 +139,7 @@ function renderContent(format, parsed, version) {
|
|
|
135
139
|
|
|
136
140
|
// Insere/atualiza o bloco spec-wave num arquivo compartilhado (AGENTS.md),
|
|
137
141
|
// preservando o restante do conteúdo. Idempotente via marcadores.
|
|
138
|
-
function mergeAgentsFile(destPath, block) {
|
|
142
|
+
export function mergeAgentsFile(destPath, block) {
|
|
139
143
|
const existing = existsSync(destPath) ? readFileSync(destPath, 'utf-8') : '';
|
|
140
144
|
const blockRe = new RegExp(
|
|
141
145
|
`${escapeRe(BLOCK_START)}[\\s\\S]*?${escapeRe(BLOCK_END)}\\n?`,
|
|
@@ -151,9 +155,18 @@ function escapeRe(s) {
|
|
|
151
155
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
152
156
|
}
|
|
153
157
|
|
|
158
|
+
// Extrai o bloco spec-wave (entre marcadores) de um arquivo compartilhado
|
|
159
|
+
// (AGENTS.md). Retorna o texto do bloco incluindo os marcadores, ou null.
|
|
160
|
+
export function extractAgentsBlock(fileContent) {
|
|
161
|
+
const m = fileContent.match(
|
|
162
|
+
new RegExp(`${escapeRe(BLOCK_START)}[\\s\\S]*?${escapeRe(BLOCK_END)}`),
|
|
163
|
+
);
|
|
164
|
+
return m ? m[0] : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
154
167
|
// Resolve o alvo do agente para um destino concreto no escopo escolhido.
|
|
155
168
|
// Retorna null quando o agente não suporta o escopo global.
|
|
156
|
-
function resolveDest(target, baseDir, isGlobal) {
|
|
169
|
+
export function resolveDest(target, baseDir, isGlobal) {
|
|
157
170
|
const rel = isGlobal ? target.global : target.project;
|
|
158
171
|
if (!rel) return null;
|
|
159
172
|
const format = isGlobal && target.globalFormat ? target.globalFormat : target.format;
|
|
@@ -161,7 +174,7 @@ function resolveDest(target, baseDir, isGlobal) {
|
|
|
161
174
|
}
|
|
162
175
|
|
|
163
176
|
// Retorna true se algum dos sinais de detecção existir em baseDir.
|
|
164
|
-
function isDetected(target, baseDir) {
|
|
177
|
+
export function isDetected(target, baseDir) {
|
|
165
178
|
return target.detect.some((sig) => existsSync(path.join(baseDir, sig)));
|
|
166
179
|
}
|
|
167
180
|
|
package/src/commands/qa.mjs
CHANGED
|
@@ -2,12 +2,12 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { resolveToken } from '../api/auth.mjs';
|
|
4
4
|
import { getIssue, getPR, commentOnIssue } from '../api/github-rest.mjs';
|
|
5
|
-
import { addProjectItem, setItemSingleSelect, getSingleSelectField, getIssueParent } from '../api/github-graphql.mjs';
|
|
5
|
+
import { addProjectItem, setItemSingleSelect, getSingleSelectField, getIssueParent, getItemSingleSelectValue } from '../api/github-graphql.mjs';
|
|
6
6
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
7
|
-
import { CONFIG_FILE, STATUS_OPTIONS } from '../config.mjs';
|
|
7
|
+
import { CONFIG_FILE, STATUS_OPTIONS, STAGE_ORDER, PROGRESS_TODO } from '../config.mjs';
|
|
8
8
|
|
|
9
9
|
const QA_STAGE = STATUS_OPTIONS.find(s => s.name.includes('QA'))?.name;
|
|
10
|
-
const TODO_STATUS =
|
|
10
|
+
const TODO_STATUS = PROGRESS_TODO;
|
|
11
11
|
|
|
12
12
|
function extractIssueNumbers(body) {
|
|
13
13
|
if (!body) return [];
|
|
@@ -50,9 +50,17 @@ async function resolveFeatureIssue(token, owner, repo, issueNumber) {
|
|
|
50
50
|
return null;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// Avança para a Etapa "🧪 QA" e reinicia o Status para "Todo". Uma issue só
|
|
54
|
+
// AVANÇA: se já estiver em QA ou etapa posterior, não é tocada (retorna false).
|
|
53
55
|
async function setQA(token, project, etapaField, statusField, nodeId) {
|
|
54
56
|
const itemId = await addProjectItem(token, project.id, nodeId);
|
|
55
57
|
if (etapaField?.id && QA_STAGE) {
|
|
58
|
+
const current = await getItemSingleSelectValue(token, itemId, etapaField.id).catch(() => null);
|
|
59
|
+
const curIdx = current ? STAGE_ORDER.indexOf(current) : -1;
|
|
60
|
+
const tgtIdx = STAGE_ORDER.indexOf(QA_STAGE);
|
|
61
|
+
if (curIdx !== -1 && tgtIdx !== -1 && curIdx >= tgtIdx) {
|
|
62
|
+
return false; // já está em QA ou adiante — não retrocede
|
|
63
|
+
}
|
|
56
64
|
const optionId = etapaField.options?.[QA_STAGE];
|
|
57
65
|
if (optionId) await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
|
|
58
66
|
}
|
|
@@ -60,6 +68,7 @@ async function setQA(token, project, etapaField, statusField, nodeId) {
|
|
|
60
68
|
const optionId = statusField.options?.[TODO_STATUS];
|
|
61
69
|
if (optionId) await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
|
|
62
70
|
}
|
|
71
|
+
return true;
|
|
63
72
|
}
|
|
64
73
|
|
|
65
74
|
export async function qa({ prNumber }) {
|
|
@@ -115,9 +124,13 @@ export async function qa({ prNumber }) {
|
|
|
115
124
|
if (!feature || seen.has(feature.number)) continue;
|
|
116
125
|
seen.add(feature.number);
|
|
117
126
|
try {
|
|
118
|
-
await setQA(projectToken, project, etapaField, statusField, feature.node_id);
|
|
119
|
-
|
|
120
|
-
|
|
127
|
+
const moved = await setQA(projectToken, project, etapaField, statusField, feature.node_id);
|
|
128
|
+
if (moved) {
|
|
129
|
+
updated.push(`#${feature.number} ${feature.title}`);
|
|
130
|
+
console.log(`Feature #${feature.number} → "${QA_STAGE}" / Status "${TODO_STATUS}".`);
|
|
131
|
+
} else {
|
|
132
|
+
console.log(`Feature #${feature.number} já está em "${QA_STAGE}" ou etapa posterior — mantida (não retrocede).`);
|
|
133
|
+
}
|
|
121
134
|
} catch (err) {
|
|
122
135
|
console.warn(`Falha ao atualizar Feature #${feature.number}: ${err.message}`);
|
|
123
136
|
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
8
|
+
import { CONFIG_FILE, WORKFLOW_FILES, ISSUE_TEMPLATE_FILES, ALL_LABELS } from '../config.mjs';
|
|
9
|
+
import { getProjectSnapshot } from '../api/github-graphql.mjs';
|
|
10
|
+
import {
|
|
11
|
+
getFileContent, upsertFile, listLabels, createLabel, updateLabel,
|
|
12
|
+
} from '../api/github-rest.mjs';
|
|
13
|
+
import {
|
|
14
|
+
TARGETS, SKILL_SOURCE, CLI_VERSION, parseSkill, renderContent,
|
|
15
|
+
mergeAgentsFile, resolveDest, isDetected, extractAgentsBlock,
|
|
16
|
+
} from './install-skill.mjs';
|
|
17
|
+
|
|
18
|
+
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const TEMPLATES_DIR = path.join(__dir, '..', 'templates');
|
|
20
|
+
function readTemplate(...parts) {
|
|
21
|
+
return readFileSync(path.join(TEMPLATES_DIR, ...parts), 'utf-8');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Arquivos do repo gerenciados pela CLI (comparados com o template empacotado).
|
|
25
|
+
const REPO_FILES = [
|
|
26
|
+
...WORKFLOW_FILES.map(f => ({ repoPath: `.github/workflows/${f}`, template: ['workflows', f] })),
|
|
27
|
+
...ISSUE_TEMPLATE_FILES.map(f => ({ repoPath: `.github/ISSUE_TEMPLATE/${f}`, template: ['issue', f] })),
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
// Detecta se a skill instalada em cada agente diverge da versão atual da CLI.
|
|
31
|
+
// Retorna os alvos desatualizados (conteúdo diferente ou ausente).
|
|
32
|
+
function detectSkill(parsed, baseDir, isGlobal) {
|
|
33
|
+
const jobs = [];
|
|
34
|
+
for (const target of TARGETS) {
|
|
35
|
+
if (!isDetected(target, baseDir)) continue;
|
|
36
|
+
const dest = resolveDest(target, baseDir, isGlobal);
|
|
37
|
+
if (!dest) continue;
|
|
38
|
+
const desired = renderContent(dest.format, parsed, CLI_VERSION);
|
|
39
|
+
const existing = existsSync(dest.path) ? readFileSync(dest.path, 'utf-8') : null;
|
|
40
|
+
let reason = null;
|
|
41
|
+
if (existing === null) {
|
|
42
|
+
reason = 'ausente';
|
|
43
|
+
} else if (dest.format === 'agents') {
|
|
44
|
+
const block = extractAgentsBlock(existing);
|
|
45
|
+
if (block === null) reason = 'bloco ausente';
|
|
46
|
+
else if (block.trim() !== desired.trim()) reason = 'desatualizada';
|
|
47
|
+
} else if (existing !== desired) {
|
|
48
|
+
reason = 'desatualizada';
|
|
49
|
+
}
|
|
50
|
+
if (reason) jobs.push({ target, dest, desired, reason });
|
|
51
|
+
}
|
|
52
|
+
return jobs;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Aplica a atualização de uma skill (grava o arquivo / faz merge no AGENTS.md).
|
|
56
|
+
function applySkill(job) {
|
|
57
|
+
const content = job.dest.format === 'agents'
|
|
58
|
+
? mergeAgentsFile(job.dest.path, job.desired)
|
|
59
|
+
: job.desired;
|
|
60
|
+
mkdirSync(path.dirname(job.dest.path), { recursive: true });
|
|
61
|
+
writeFileSync(job.dest.path, content, 'utf-8');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Compara ALL_LABELS com as labels do repo. color no config é hex maiúsculo; a
|
|
65
|
+
// API retorna minúsculo — daí o toLowerCase() na comparação.
|
|
66
|
+
function diffLabels(existing) {
|
|
67
|
+
const byName = new Map(existing.map(l => [l.name, l]));
|
|
68
|
+
const missing = [];
|
|
69
|
+
const changed = [];
|
|
70
|
+
for (const label of ALL_LABELS) {
|
|
71
|
+
const cur = byName.get(label.name);
|
|
72
|
+
if (!cur) {
|
|
73
|
+
missing.push(label);
|
|
74
|
+
} else if (
|
|
75
|
+
cur.color.toLowerCase() !== label.color.toLowerCase() ||
|
|
76
|
+
(cur.description || '') !== (label.description || '')
|
|
77
|
+
) {
|
|
78
|
+
changed.push(label);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { missing, changed };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function update(options = {}) {
|
|
85
|
+
p.intro(chalk.bold(`spec-wave update (CLI v${CLI_VERSION})`));
|
|
86
|
+
|
|
87
|
+
const isGlobal = !!options.global;
|
|
88
|
+
const baseDir = isGlobal ? homedir() : process.cwd();
|
|
89
|
+
|
|
90
|
+
// ---------- Detecção ----------
|
|
91
|
+
// 1) Skill (por agente detectado).
|
|
92
|
+
const parsed = parseSkill(readFileSync(SKILL_SOURCE, 'utf-8'));
|
|
93
|
+
const skillJobs = options.skipSkill ? [] : detectSkill(parsed, baseDir, isGlobal);
|
|
94
|
+
|
|
95
|
+
// 2) Config + repo dependem do .spec-wave.json local do repo atual.
|
|
96
|
+
const configPath = path.join(process.cwd(), CONFIG_FILE);
|
|
97
|
+
let config = null;
|
|
98
|
+
if (existsSync(configPath)) {
|
|
99
|
+
try {
|
|
100
|
+
config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
101
|
+
} catch (err) {
|
|
102
|
+
p.log.warn(`${CONFIG_FILE} corrompido (${err.message}); pulando config/repo.`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const doConfig = !options.skipConfig && !!config;
|
|
107
|
+
const doRepo = !options.skipRepo && !!config?.owner && !!config?.repo;
|
|
108
|
+
|
|
109
|
+
// Token resolvido sob demanda (necessário só para repo/config remoto).
|
|
110
|
+
let token;
|
|
111
|
+
let tokenError;
|
|
112
|
+
async function getToken() {
|
|
113
|
+
if (token || tokenError) return token;
|
|
114
|
+
try {
|
|
115
|
+
token = await resolveToken();
|
|
116
|
+
} catch (err) {
|
|
117
|
+
tokenError = err;
|
|
118
|
+
}
|
|
119
|
+
return token;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 2a) Config desatualizado? (versão divergente ou formato legado).
|
|
123
|
+
let configStale = null;
|
|
124
|
+
if (doConfig) {
|
|
125
|
+
const legacy = !config.project?.fields;
|
|
126
|
+
if (config.version !== CLI_VERSION || legacy) {
|
|
127
|
+
configStale = {
|
|
128
|
+
reason: legacy ? 'formato legado (sem project.fields)' : `versão ${config.version ?? '?'} ≠ ${CLI_VERSION}`,
|
|
129
|
+
canApply: !!config.project?.id,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 2b) Arquivos do repo e labels divergentes (exige token + rede).
|
|
135
|
+
let repoFiles = [];
|
|
136
|
+
let labelDiff = { missing: [], changed: [] };
|
|
137
|
+
let repoChecked = false;
|
|
138
|
+
if (doRepo) {
|
|
139
|
+
const s = p.spinner();
|
|
140
|
+
s.start('Comparando arquivos e labels do repositório...');
|
|
141
|
+
const tk = await getToken();
|
|
142
|
+
if (!tk) {
|
|
143
|
+
s.stop('');
|
|
144
|
+
p.log.warn(`Sem token do GitHub (${tokenError?.message ?? 'indisponível'}); pulando verificação do repo.`);
|
|
145
|
+
} else {
|
|
146
|
+
const { owner, repo } = config;
|
|
147
|
+
try {
|
|
148
|
+
for (const f of REPO_FILES) {
|
|
149
|
+
const remote = await getFileContent(tk, owner, repo, f.repoPath);
|
|
150
|
+
const local = readTemplate(...f.template);
|
|
151
|
+
if (remote === null) repoFiles.push({ ...f, reason: 'ausente', local });
|
|
152
|
+
else if (remote !== local) repoFiles.push({ ...f, reason: 'desatualizado', local });
|
|
153
|
+
}
|
|
154
|
+
labelDiff = diffLabels(await listLabels(tk, owner, repo));
|
|
155
|
+
repoChecked = true;
|
|
156
|
+
s.stop('Repositório comparado.');
|
|
157
|
+
} catch (err) {
|
|
158
|
+
s.stop('');
|
|
159
|
+
p.log.warn(`Falha ao comparar o repo: ${err.message}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------- Resumo ----------
|
|
165
|
+
const labelTotal = labelDiff.missing.length + labelDiff.changed.length;
|
|
166
|
+
const total = skillJobs.length + (configStale ? 1 : 0) + repoFiles.length + labelTotal;
|
|
167
|
+
|
|
168
|
+
if (total === 0) {
|
|
169
|
+
p.log.success('Tudo já está atualizado para a versão atual da CLI.');
|
|
170
|
+
p.outro('Nada a fazer.');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const lines = [];
|
|
175
|
+
if (skillJobs.length) {
|
|
176
|
+
lines.push(chalk.bold('Skill:'));
|
|
177
|
+
for (const j of skillJobs) lines.push(` ${chalk.yellow('↻')} ${j.target.name} (${j.reason})\n ${chalk.dim(j.dest.path)}`);
|
|
178
|
+
}
|
|
179
|
+
if (configStale) {
|
|
180
|
+
lines.push(chalk.bold('Config local:'));
|
|
181
|
+
lines.push(` ${chalk.yellow('↻')} ${CONFIG_FILE} — ${configStale.reason}` +
|
|
182
|
+
(configStale.canApply ? '' : chalk.dim(' (sem project.id — rode `init` sem --skip-project)')));
|
|
183
|
+
}
|
|
184
|
+
if (repoFiles.length) {
|
|
185
|
+
lines.push(chalk.bold('Arquivos do repo:'));
|
|
186
|
+
for (const f of repoFiles) lines.push(` ${chalk.yellow('↻')} ${f.repoPath} (${f.reason})`);
|
|
187
|
+
}
|
|
188
|
+
if (labelTotal) {
|
|
189
|
+
lines.push(chalk.bold('Labels:'));
|
|
190
|
+
if (labelDiff.missing.length) lines.push(` ${chalk.yellow('+')} criar: ${labelDiff.missing.map(l => l.name).join(', ')}`);
|
|
191
|
+
if (labelDiff.changed.length) lines.push(` ${chalk.yellow('↻')} atualizar: ${labelDiff.changed.map(l => l.name).join(', ')}`);
|
|
192
|
+
}
|
|
193
|
+
p.note(lines.join('\n'), `${total} item(ns) desatualizado(s)`);
|
|
194
|
+
|
|
195
|
+
if (options.dryRun) {
|
|
196
|
+
p.outro('Dry-run: nada foi alterado.');
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!options.yes) {
|
|
201
|
+
const ok = await p.confirm({ message: `Aplicar as ${total} atualização(ões)?`, initialValue: true });
|
|
202
|
+
if (p.isCancel(ok) || !ok) {
|
|
203
|
+
p.cancel('Update cancelado.');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------- Aplicação ----------
|
|
209
|
+
// Skill
|
|
210
|
+
for (const job of skillJobs) {
|
|
211
|
+
try {
|
|
212
|
+
applySkill(job);
|
|
213
|
+
p.log.success(`Skill atualizada: ${job.target.name}`);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
p.log.error(`Falha ao atualizar skill (${job.target.name}): ${err.message}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Config (.spec-wave.json) — reconsulta o Project e reescreve local.
|
|
220
|
+
if (configStale) {
|
|
221
|
+
if (!configStale.canApply) {
|
|
222
|
+
p.log.warn(`${CONFIG_FILE}: sem project.id — pulei. Rode \`npx @spec-wave/cli init\` (sem --skip-project).`);
|
|
223
|
+
} else {
|
|
224
|
+
const tk = await getToken();
|
|
225
|
+
if (!tk) {
|
|
226
|
+
p.log.warn(`${CONFIG_FILE}: sem token — pulei. (${tokenError?.message ?? ''})`);
|
|
227
|
+
} else {
|
|
228
|
+
const s = p.spinner();
|
|
229
|
+
s.start('Atualizando .spec-wave.json...');
|
|
230
|
+
try {
|
|
231
|
+
const snapshot = await getProjectSnapshot(tk, config.project.id);
|
|
232
|
+
if (!snapshot) throw new Error('Project não encontrado');
|
|
233
|
+
const { etapaFieldId: _e, stageOptions: _s, ...projectRest } = config.project;
|
|
234
|
+
const updated = {
|
|
235
|
+
...config,
|
|
236
|
+
version: CLI_VERSION,
|
|
237
|
+
project: {
|
|
238
|
+
...projectRest,
|
|
239
|
+
title: snapshot.title,
|
|
240
|
+
url: snapshot.url,
|
|
241
|
+
id: snapshot.id,
|
|
242
|
+
number: snapshot.number,
|
|
243
|
+
fields: snapshot.fields,
|
|
244
|
+
},
|
|
245
|
+
refreshedAt: new Date().toISOString(),
|
|
246
|
+
};
|
|
247
|
+
writeFileSync(configPath, JSON.stringify(updated, null, 2) + '\n');
|
|
248
|
+
s.stop(`${CONFIG_FILE} atualizado (v${CLI_VERSION}).`);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
s.stop('');
|
|
251
|
+
p.log.error(`Falha ao atualizar ${CONFIG_FILE}: ${err.message}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Arquivos do repo
|
|
258
|
+
if (repoFiles.length) {
|
|
259
|
+
const tk = await getToken();
|
|
260
|
+
const { owner, repo } = config;
|
|
261
|
+
for (const f of repoFiles) {
|
|
262
|
+
try {
|
|
263
|
+
await upsertFile(tk, owner, repo, f.repoPath, f.local, `chore: update ${path.basename(f.repoPath)} [spec-wave]`);
|
|
264
|
+
p.log.success(`Arquivo atualizado no repo: ${f.repoPath}`);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
p.log.error(`Falha ao atualizar ${f.repoPath}: ${err.message}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Labels
|
|
272
|
+
if (labelTotal) {
|
|
273
|
+
const tk = await getToken();
|
|
274
|
+
const { owner, repo } = config;
|
|
275
|
+
for (const label of labelDiff.missing) {
|
|
276
|
+
try {
|
|
277
|
+
await createLabel(tk, owner, repo, label);
|
|
278
|
+
p.log.success(`Label criada: ${label.name}`);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
p.log.error(`Falha ao criar label ${label.name}: ${err.message}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
for (const label of labelDiff.changed) {
|
|
284
|
+
try {
|
|
285
|
+
await updateLabel(tk, owner, repo, label);
|
|
286
|
+
p.log.success(`Label atualizada: ${label.name}`);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
p.log.error(`Falha ao atualizar label ${label.name}: ${err.message}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const committedRepo = repoFiles.length > 0;
|
|
294
|
+
p.outro(
|
|
295
|
+
'Update concluído.' +
|
|
296
|
+
(skillJobs.length ? ' Recarregue o agente para pegar a skill nova.' : '') +
|
|
297
|
+
(configStale?.canApply ? ` Faça commit do ${CONFIG_FILE}.` : '') +
|
|
298
|
+
(committedRepo ? ' Arquivos do repo foram commitados no remoto.' : '')
|
|
299
|
+
);
|
|
300
|
+
}
|
package/src/config.mjs
CHANGED
|
@@ -51,13 +51,22 @@ export const STATUS_OPTIONS = [
|
|
|
51
51
|
{ name: '🎉 Done', color: 'GREEN' },
|
|
52
52
|
];
|
|
53
53
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
54
|
+
// ⚠️ Dois campos DISTINTOS no board (não confundir):
|
|
55
|
+
// • "Etapa" (campo custom = as opções de STATUS_OPTIONS acima): as colunas do
|
|
56
|
+
// kanban. Determina a DIREÇÃO do fluxo — uma issue só AVANÇA, nunca volta.
|
|
57
|
+
// • "Status" (campo nativo: Todo/In Progress/Done): o PROGRESSO dentro da etapa
|
|
58
|
+
// atual. Ao avançar de etapa, o Status reinicia em "Todo".
|
|
59
|
+
|
|
60
|
+
// Etapas (campo Etapa) referenciadas pelo fluxo de implementação.
|
|
61
|
+
export const STAGE_DEVELOPMENT = STATUS_OPTIONS.find(s => s.name.includes('Desenvolvimento')).name;
|
|
60
62
|
export const STAGE_CODE_REVIEW = STATUS_OPTIONS.find(s => s.name.includes('Code Review')).name;
|
|
63
|
+
// Ordem canônica das etapas — usada para garantir que uma issue só AVANÇA.
|
|
64
|
+
export const STAGE_ORDER = STATUS_OPTIONS.map(s => s.name);
|
|
65
|
+
|
|
66
|
+
// Valores do campo nativo "Status" (progresso dentro da etapa).
|
|
67
|
+
export const PROGRESS_TODO = 'Todo';
|
|
68
|
+
export const PROGRESS_IN_PROGRESS = 'In Progress';
|
|
69
|
+
export const PROGRESS_DONE = 'Done';
|
|
61
70
|
|
|
62
71
|
export const CUSTOM_FIELDS = [
|
|
63
72
|
{
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: spec-wave
|
|
3
3
|
description: "Use when the user wants to set up a spec-driven GitHub workflow, create a Feature issue, generate spec.md or plan.md, decompose a Feature into Stories/Tasks, write RFC documentation, or audit and fix a Pull Request. Implements the RFC-001 workflow with GitHub Projects v2, labels, and AI-powered GitHub Actions."
|
|
4
|
-
argument-hint: "[info|setup|issue|feature|spec|plan|ready|decompose|implement|uninstall|rfc|fix-pr] [target]"
|
|
4
|
+
argument-hint: "[info|setup|update|issue|feature|spec|plan|ready|decompose|implement|uninstall|rfc|fix-pr] [target]"
|
|
5
5
|
user-invocable: true
|
|
6
6
|
allowed-tools:
|
|
7
7
|
- Bash(npx @spec-wave/cli *)
|
|
@@ -27,7 +27,7 @@ Este skill guia o usuário pelo fluxo spec-driven definido no RFC-001.
|
|
|
27
27
|
|
|
28
28
|
> **Antes de responder a qualquer sub-comando**, leia o arquivo `rfc/rfc-integrate-spec-kit-into-kanban.md` se ele existir no diretório atual, para embasar suas respostas no processo real da equipe.
|
|
29
29
|
|
|
30
|
-
> **Verifique se esta skill está atualizada:** logo no topo deste arquivo há um banner `spec-wave skill vX.Y.Z` (inserido na instalação). Compare com `npx @spec-wave/cli --version`. Se a CLI for **mais recente
|
|
30
|
+
> **Verifique se esta skill está atualizada:** logo no topo deste arquivo há um banner `spec-wave skill vX.Y.Z` (inserido na instalação). Compare com `npx @spec-wave/cli --version`. Se a CLI for **mais recente** (ou o banner estiver ausente = instalada por versão antiga), esta skill está desatualizada — avise o usuário e sugira `npx @spec-wave/cli update` (detecta e atualiza só o que mudou: skill, `.spec-wave.json` e workflows/labels do repo) ou, para atualizar só a skill, `npx @spec-wave/cli install-skill --force`. A skill é uma cópia estática e **não** acompanha o `npx` sozinha.
|
|
31
31
|
|
|
32
32
|
---
|
|
33
33
|
|
|
@@ -142,6 +142,16 @@ Mesmas flags do `issue` (exceto `--type`, fixo em `feature`). Mantido para o flu
|
|
|
142
142
|
|
|
143
143
|
> Use quando o `.spec-wave.json` estiver desatualizado: repos inicializados por uma versão antiga (sem `etapaFieldId`/`stageOptions`), Project renomeado, ou versão da CLI divergente. Escreve no arquivo **local** — faça commit depois.
|
|
144
144
|
|
|
145
|
+
### `@spec-wave/cli update` — atualiza tudo que ficou para trás (só o que mudou)
|
|
146
|
+
| Flag | Tipo | Descrição |
|
|
147
|
+
|------|------|-----------|
|
|
148
|
+
| `--global` | flag | Verifica a skill no escopo do usuário (padrão: projeto). |
|
|
149
|
+
| `--skip-skill` / `--skip-config` / `--skip-repo` | flag | Pula a categoria correspondente. |
|
|
150
|
+
| `--dry-run` | flag | Mostra o que seria atualizado sem alterar nada. |
|
|
151
|
+
| `--yes` | flag | Aplica sem pedir confirmação. |
|
|
152
|
+
|
|
153
|
+
> Detecta e atualiza **somente o que divergiu** da versão atual da CLI: a **skill** instalada (por agente), o **`.spec-wave.json`** local (se versão/formato divergir) e os **workflows/labels** do repo (compara com os templates empacotados). Interativo por padrão (mostra o plano e confirma). É o atalho recomendado após atualizar a CLI.
|
|
154
|
+
|
|
145
155
|
### `@spec-wave/cli generate-plan` · `generate-spec` · `validate` · `decompose`
|
|
146
156
|
| Flag | Tipo | Descrição |
|
|
147
157
|
|------|------|-----------|
|
|
@@ -156,7 +166,7 @@ Mesmas flags do `issue` (exceto `--type`, fixo em `feature`). Mantido para o flu
|
|
|
156
166
|
| `--feature-dir <path>` | string | Caminho `docs/features/<slug>` para anexar `spec.md`/`plan.md` como contexto (sobrescreve a resolução automática). |
|
|
157
167
|
| `--dry-run` | flag | Monta o contexto e imprime o comando do spec-kit **sem executar**. |
|
|
158
168
|
|
|
159
|
-
> 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 instruções para o agente implementar as Tasks **sequencialmente, uma por vez
|
|
169
|
+
> 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 instruções para o agente implementar as Tasks **sequencialmente, uma por vez**, usando o campo **Status** (Todo → In Progress → Done) para o progresso *dentro* da Etapa 🚧 Desenvolvimento — sem trocar a Etapa das tasks (nunca duas com Status "In Progress" ao mesmo tempo). **Ao concluir toda a Story**: fazer o commit, abrir o PR e **avançar a Etapa** da **Feature, Story e todas as Tasks juntas** para **👀 Code Review**, reiniciando o **Status** de cada uma para **Todo**. Etapa só avança (nunca volta); Status mede o progresso dentro da etapa.
|
|
160
170
|
|
|
161
171
|
---
|
|
162
172
|
|
|
@@ -175,6 +185,26 @@ Mostra se o repositório atual já foi configurado com o spec-wave.
|
|
|
175
185
|
|
|
176
186
|
---
|
|
177
187
|
|
|
188
|
+
### `/spec-wave update`
|
|
189
|
+
|
|
190
|
+
Traz tudo para a versão atual da CLI, atualizando **só o que mudou**: a skill instalada, o `.spec-wave.json` local e os workflows/labels do repo.
|
|
191
|
+
|
|
192
|
+
**Passos:**
|
|
193
|
+
1. **Sempre comece com `--dry-run`** para inspecionar o que está desatualizado sem alterar nada:
|
|
194
|
+
```bash
|
|
195
|
+
npx @spec-wave/cli update --dry-run
|
|
196
|
+
```
|
|
197
|
+
2. Mostre ao usuário o resumo (skill / config / arquivos do repo / labels que divergiram). Se **nada** estiver desatualizado, informe que já está tudo na versão atual e encerre.
|
|
198
|
+
3. Se o usuário aprovar, aplique:
|
|
199
|
+
```bash
|
|
200
|
+
npx @spec-wave/cli update --yes
|
|
201
|
+
```
|
|
202
|
+
- Escopos podem ser limitados com `--skip-skill`, `--skip-config`, `--skip-repo`.
|
|
203
|
+
- Atualizações de **arquivos do repo** são commitadas no remoto; o **`.spec-wave.json`** é local (lembre o usuário de commitá-lo).
|
|
204
|
+
4. Se a skill foi atualizada, oriente recarregar/reiniciar o agente para pegar a nova versão.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
178
208
|
### `/spec-wave setup`
|
|
179
209
|
|
|
180
210
|
Configura o spec-wave no repositório. Você dirige o `init` com flags — **nunca rode `npx @spec-wave/cli init` sem `--repo`** (abre o wizard interativo que você não controla).
|
|
@@ -366,7 +396,7 @@ Aciona o spec-kit para implementar uma **Story** (todas as suas Tasks) ou uma **
|
|
|
366
396
|
npx @spec-wave/cli implement <número> --dry-run
|
|
367
397
|
```
|
|
368
398
|
3. Mostre ao usuário o contexto montado em `.spec-wave/implement-<número>.md` e o comando. Esse arquivo contém as **instruções de execução sequencial**: implemente as Tasks **uma por vez** — mova a task para **🚧 Desenvolvimento** só ao iniciá-la e para **🎉 Done** ao concluí-la, antes de passar para a próxima. **Nunca** coloque várias tasks em "in progress" ao mesmo tempo.
|
|
369
|
-
4. **Se você (agente) for implementar diretamente** (sem `specKit.command`): siga o contexto task por task
|
|
399
|
+
4. **Se você (agente) for implementar diretamente** (sem `specKit.command`): siga o contexto task por task. Para cada task, use o campo **Status** (In Progress ao começar → Done ao concluir) *dentro* da Etapa 🚧 Desenvolvimento — não troque a Etapa da task. Atualize os campos via `gh`. **Ao concluir toda a Story**: faça o commit, abra o PR e **avance a Etapa** da **Feature, Story e todas as Tasks juntas** para **👀 Code Review**, reiniciando o **Status** de cada uma para **Todo**. Lembre: Etapa só avança (nunca volta); Status é o progresso dentro da etapa.
|
|
370
400
|
5. Se o usuário aprovar e o spec-kit estiver configurado, rode sem `--dry-run`:
|
|
371
401
|
```bash
|
|
372
402
|
npx @spec-wave/cli implement <número>
|
|
@@ -374,7 +404,7 @@ Aciona o spec-kit para implementar uma **Story** (todas as suas Tasks) ou uma **
|
|
|
374
404
|
- 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}`).
|
|
375
405
|
- 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.
|
|
376
406
|
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.
|
|
377
|
-
7. Ao final (Story implementada, commit feito, PR aberto e Feature
|
|
407
|
+
7. Ao final (Story implementada, commit feito, PR aberto e Feature + Story + Tasks em **👀 Code Review**): confirme o resultado com o usuário e oriente a revisão do PR.
|
|
378
408
|
|
|
379
409
|
---
|
|
380
410
|
|