@spec-wave/cli 0.5.8 → 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 +68 -27
- package/src/commands/info.mjs +3 -1
- package/src/commands/init.mjs +27 -21
- package/src/commands/install-skill.mjs +53 -23
- package/src/commands/qa.mjs +19 -6
- package/src/commands/update.mjs +300 -0
- package/src/config.mjs +20 -0
- package/src/templates/skill/SKILL.md +72 -39
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';
|
|
@@ -14,14 +17,17 @@ import { slugify } from '../lib/slugify.mjs';
|
|
|
14
17
|
const WORK_DIR = '.spec-wave';
|
|
15
18
|
|
|
16
19
|
// Sobe a cadeia de pais (Task → Story → Feature) até achar uma issue do tipo
|
|
17
|
-
// "Feature" e devolve
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
+
// "Feature" e devolve { number, title } — usado para resolver docs/features/<slug>
|
|
21
|
+
// e para as instruções de fim de Story (mover a Feature para Code Review). Limita
|
|
22
|
+
// a profundidade para evitar loops em dados inconsistentes.
|
|
23
|
+
async function resolveFeature(token, startNodeId) {
|
|
20
24
|
let current = startNodeId;
|
|
21
25
|
for (let depth = 0; depth < 5 && current; depth++) {
|
|
22
26
|
const parent = await getIssueParent(token, current);
|
|
23
27
|
if (!parent) return null;
|
|
24
|
-
if (detectIssueType({ title: parent.title }) === 'Feature')
|
|
28
|
+
if (detectIssueType({ title: parent.title }) === 'Feature') {
|
|
29
|
+
return { number: parent.number, title: parent.title };
|
|
30
|
+
}
|
|
25
31
|
current = parent.nodeId;
|
|
26
32
|
}
|
|
27
33
|
return null;
|
|
@@ -40,7 +46,7 @@ function readSpecPlan(featureDir) {
|
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
// Monta o markdown de contexto que será entregue ao spec-kit implement.
|
|
43
|
-
function buildContext({ type, issue, tasks, spec, plan, specPath, planPath }) {
|
|
49
|
+
function buildContext({ type, issue, tasks, feature, spec, plan, specPath, planPath }) {
|
|
44
50
|
const lines = [];
|
|
45
51
|
lines.push(`# Contexto de implementação — ${type} #${issue.number}`);
|
|
46
52
|
lines.push('');
|
|
@@ -50,32 +56,66 @@ function buildContext({ type, issue, tasks, spec, plan, specPath, planPath }) {
|
|
|
50
56
|
lines.push(issue.body.trim());
|
|
51
57
|
}
|
|
52
58
|
|
|
53
|
-
//
|
|
54
|
-
// (
|
|
55
|
-
|
|
56
|
-
|
|
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.
|
|
57
63
|
lines.push('');
|
|
58
|
-
lines.push('## Instruções
|
|
64
|
+
lines.push('## Instruções de execução (uma task por vez, sequencial)');
|
|
59
65
|
lines.push('');
|
|
60
66
|
lines.push(
|
|
61
|
-
'
|
|
62
|
-
|
|
63
|
-
(
|
|
64
|
-
? `da Story #${issue.number} e de cada Task: ${inProgress.filter(n => n !== issue.number).map(n => `#${n}`).join(', ')}.`
|
|
65
|
-
: `da Task #${issue.number}.`)
|
|
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}**.`
|
|
66
70
|
);
|
|
71
|
+
lines.push('');
|
|
72
|
+
if (type === 'Story') {
|
|
73
|
+
lines.push(
|
|
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.'
|
|
76
|
+
);
|
|
77
|
+
lines.push('');
|
|
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.');
|
|
84
|
+
lines.push('');
|
|
85
|
+
lines.push(`3. **Ao concluir TODA a Story** (todas as Tasks com Status ${PROGRESS_DONE}):`);
|
|
86
|
+
lines.push(' 1. Faça o **commit** de todas as mudanças da implementação.');
|
|
87
|
+
lines.push(` 2. Abra o **Pull Request** da Story #${issue.number}.`);
|
|
88
|
+
lines.push(
|
|
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.'
|
|
93
|
+
);
|
|
94
|
+
} else {
|
|
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}**.`);
|
|
103
|
+
lines.push('');
|
|
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.`);
|
|
105
|
+
}
|
|
106
|
+
lines.push('');
|
|
67
107
|
lines.push(
|
|
68
|
-
|
|
69
|
-
'
|
|
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.'
|
|
70
110
|
);
|
|
71
111
|
|
|
72
112
|
lines.push('');
|
|
73
|
-
lines.push(`## Tasks a implementar (${tasks.length})`);
|
|
74
|
-
|
|
113
|
+
lines.push(`## Tasks a implementar — NESTA ORDEM (${tasks.length})`);
|
|
114
|
+
tasks.forEach((t, i) => {
|
|
75
115
|
lines.push('');
|
|
76
|
-
lines.push(`### #${t.number} ${t.title}`);
|
|
116
|
+
lines.push(`### ${i + 1}. #${t.number} ${t.title}`);
|
|
77
117
|
if (t.body && t.body.trim()) lines.push(t.body.trim());
|
|
78
|
-
}
|
|
118
|
+
});
|
|
79
119
|
|
|
80
120
|
if (spec) {
|
|
81
121
|
lines.push('');
|
|
@@ -171,11 +211,12 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
|
|
|
171
211
|
return;
|
|
172
212
|
}
|
|
173
213
|
|
|
174
|
-
// 4. Resolve spec.md/plan.md
|
|
214
|
+
// 4. Resolve a Feature (pai na cadeia) — para spec.md/plan.md e para as
|
|
215
|
+
// instruções de fim de Story (mover Feature + Story para Code Review).
|
|
216
|
+
const feature = await resolveFeature(token, issue.node_id);
|
|
175
217
|
let featureDir = featureDirOpt;
|
|
176
|
-
if (!featureDir) {
|
|
177
|
-
|
|
178
|
-
if (featureTitle) featureDir = path.join('docs', 'features', slugify(featureTitle));
|
|
218
|
+
if (!featureDir && feature?.title) {
|
|
219
|
+
featureDir = path.join('docs', 'features', slugify(feature.title));
|
|
179
220
|
}
|
|
180
221
|
let specPlan = { spec: null, plan: null, specPath: null, planPath: null };
|
|
181
222
|
if (featureDir && existsSync(featureDir)) {
|
|
@@ -187,7 +228,7 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
|
|
|
187
228
|
}
|
|
188
229
|
|
|
189
230
|
// 5. Monta e grava o arquivo de contexto.
|
|
190
|
-
const context = buildContext({ type, issue, tasks, ...specPlan });
|
|
231
|
+
const context = buildContext({ type, issue, tasks, feature, ...specPlan });
|
|
191
232
|
mkdirSync(WORK_DIR, { recursive: true });
|
|
192
233
|
const tasksFile = path.join(WORK_DIR, `implement-${issueNumber}.md`);
|
|
193
234
|
writeFileSync(tasksFile, context);
|
package/src/commands/info.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import * as p from '@clack/prompts';
|
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { readFileSync, existsSync } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { CONFIG_FILE } from '../config.mjs';
|
|
5
|
+
import { CONFIG_FILE, PORTAL_URL } from '../config.mjs';
|
|
6
6
|
|
|
7
7
|
// Lê o marcador .spec-wave.json do repositório atual (cwd) e reporta se o
|
|
8
8
|
// spec-wave já foi inicializado. Usado pela skill para decidir entre mostrar
|
|
@@ -17,6 +17,7 @@ export async function info(options = {}) {
|
|
|
17
17
|
}
|
|
18
18
|
p.intro(chalk.bold('spec-wave info'));
|
|
19
19
|
p.log.warn(`Este repositório ${chalk.bold('não foi inicializado')} (sem ${CONFIG_FILE}).`);
|
|
20
|
+
p.log.info(`🌐 Acesse o Portal Web da ferramenta em ${chalk.cyan(PORTAL_URL)}`);
|
|
20
21
|
p.outro('Execute `npx @spec-wave/cli init` para configurar.');
|
|
21
22
|
return;
|
|
22
23
|
}
|
|
@@ -50,5 +51,6 @@ export async function info(options = {}) {
|
|
|
50
51
|
`${chalk.dim('Criado em:')} ${config.initializedAt ?? '?'}`,
|
|
51
52
|
'Configuração'
|
|
52
53
|
);
|
|
54
|
+
p.log.info(`🌐 Acesse o Portal Web da ferramenta em ${chalk.cyan(PORTAL_URL)}`);
|
|
53
55
|
p.outro('Use `/spec-wave feature <descrição>` para criar uma Feature.');
|
|
54
56
|
}
|
package/src/commands/init.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as p from '@clack/prompts';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { resolveToken, verifyTokenScopes } from '../api/auth.mjs';
|
|
@@ -8,8 +8,8 @@ import { runWizard } from '../ui/wizard.mjs';
|
|
|
8
8
|
import { setupProject } from '../setup/project.mjs';
|
|
9
9
|
import { setupLabels } from '../setup/labels.mjs';
|
|
10
10
|
import { setupFiles } from '../setup/files.mjs';
|
|
11
|
-
import {
|
|
12
|
-
import { CONFIG_FILE, AI_PROVIDERS, getProvider, DEFAULT_PROVIDER } from '../config.mjs';
|
|
11
|
+
import { getFileContent } from '../api/github-rest.mjs';
|
|
12
|
+
import { CONFIG_FILE, AI_PROVIDERS, getProvider, DEFAULT_PROVIDER, PORTAL_URL } from '../config.mjs';
|
|
13
13
|
|
|
14
14
|
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
15
15
|
const pkg = JSON.parse(readFileSync(path.join(__dir, '..', '..', 'package.json'), 'utf-8'));
|
|
@@ -91,8 +91,13 @@ export async function init(options) {
|
|
|
91
91
|
if (options.skipProject) {
|
|
92
92
|
p.log.info('Pulando criação do GitHub Project (--skip-project).');
|
|
93
93
|
// Preserva o bloco project do .spec-wave.json existente (se houver).
|
|
94
|
+
// Prefere o arquivo local; recorre ao remoto para repos configurados por
|
|
95
|
+
// versões antigas (que commitavam o config direto no repo).
|
|
94
96
|
try {
|
|
95
|
-
const
|
|
97
|
+
const localConfigPath = path.join(process.cwd(), CONFIG_FILE);
|
|
98
|
+
const raw = existsSync(localConfigPath)
|
|
99
|
+
? readFileSync(localConfigPath, 'utf-8')
|
|
100
|
+
: await getFileContent(token, owner, repo, CONFIG_FILE);
|
|
96
101
|
if (raw) {
|
|
97
102
|
const existing = JSON.parse(raw);
|
|
98
103
|
if (existing.project) {
|
|
@@ -165,10 +170,13 @@ export async function init(options) {
|
|
|
165
170
|
}
|
|
166
171
|
|
|
167
172
|
// --- Marcador de configuração (.spec-wave.json) ---
|
|
168
|
-
//
|
|
169
|
-
//
|
|
173
|
+
// Gravado LOCALMENTE no diretório atual (não commitado direto no repo): é a
|
|
174
|
+
// fonte de estado persistente lida por info/refresh/uninstall/skill a partir
|
|
175
|
+
// do cwd. O usuário revisa e commita quando quiser.
|
|
176
|
+
const localConfigPath = path.join(process.cwd(), CONFIG_FILE);
|
|
177
|
+
let configWritten = false;
|
|
170
178
|
const configSpinner = p.spinner();
|
|
171
|
-
configSpinner.start(`Gravando ${CONFIG_FILE}...`);
|
|
179
|
+
configSpinner.start(`Gravando ${CONFIG_FILE} local...`);
|
|
172
180
|
try {
|
|
173
181
|
const config = {
|
|
174
182
|
version: pkg.version,
|
|
@@ -187,15 +195,9 @@ export async function init(options) {
|
|
|
187
195
|
},
|
|
188
196
|
initializedAt: new Date().toISOString(),
|
|
189
197
|
};
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
repo,
|
|
194
|
-
CONFIG_FILE,
|
|
195
|
-
JSON.stringify(config, null, 2) + '\n',
|
|
196
|
-
'chore: record spec-wave config [spec-wave]'
|
|
197
|
-
);
|
|
198
|
-
configSpinner.stop(`${CONFIG_FILE} gravado (spec-wave v${pkg.version})`);
|
|
198
|
+
writeFileSync(localConfigPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
199
|
+
configWritten = true;
|
|
200
|
+
configSpinner.stop(`${CONFIG_FILE} gravado local (spec-wave v${pkg.version})`);
|
|
199
201
|
} catch (err) {
|
|
200
202
|
configSpinner.stop('');
|
|
201
203
|
p.log.warn(`Não foi possível gravar ${CONFIG_FILE}: ${err.message}`);
|
|
@@ -214,10 +216,14 @@ export async function init(options) {
|
|
|
214
216
|
`\n${chalk.green('✓')} spec-wave configurado com sucesso!\n\n` +
|
|
215
217
|
(projectUrl ? ` Projeto: ${chalk.cyan(projectUrl)}\n\n` : '') +
|
|
216
218
|
` Próximos passos:\n` +
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
`
|
|
221
|
-
` ${
|
|
219
|
+
(configWritten
|
|
220
|
+
? ` 1. Commite o ${CONFIG_FILE} quando quiser (git add ${CONFIG_FILE} && git commit)\n`
|
|
221
|
+
: '') +
|
|
222
|
+
` ${configWritten ? '2' : '1'}. Adicione ${providerMeta.secret} como secret no repositório (provider: ${providerMeta.label})\n` +
|
|
223
|
+
` ${configWritten ? '3' : '2'}. Configure o board view para agrupar por "Etapa"\n` +
|
|
224
|
+
` ${configWritten ? '4' : '3'}. Crie uma Feature com o prefixo [FEATURE] no título\n` +
|
|
225
|
+
` ${configWritten ? '5' : '4'}. Use a skill spec-wave para guiar o fluxo\n\n` +
|
|
226
|
+
` ${chalk.dim('Para instalar a skill no seu agente: npx @spec-wave/cli install-skill')}\n\n` +
|
|
227
|
+
` 🌐 Acesse o Portal Web da ferramenta em ${chalk.cyan(PORTAL_URL)}`
|
|
222
228
|
);
|
|
223
229
|
}
|
|
@@ -9,16 +9,22 @@ import path from 'node:path';
|
|
|
9
9
|
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
10
10
|
// Fonte única da skill, publicada via "files": ["src"] no package.json.
|
|
11
11
|
const SKILL_SOURCE = path.join(__dir, '..', 'templates', 'skill', 'SKILL.md');
|
|
12
|
+
// Versão da CLI que gerou a skill instalada — carimbada no arquivo para detecção
|
|
13
|
+
// de desatualização (a skill é uma cópia estática; não acompanha o `npx` sozinha).
|
|
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;
|
|
12
18
|
|
|
13
19
|
// Marcadores usados para gravar/atualizar a skill de forma idempotente em
|
|
14
20
|
// arquivos compartilhados (AGENTS.md) — permite reinstalar sem duplicar.
|
|
15
|
-
const BLOCK_START = '<!-- spec-wave:start -->';
|
|
16
|
-
const BLOCK_END = '<!-- spec-wave:end -->';
|
|
21
|
+
export const BLOCK_START = '<!-- spec-wave:start -->';
|
|
22
|
+
export const BLOCK_END = '<!-- spec-wave:end -->';
|
|
17
23
|
|
|
18
24
|
// Registro de agentes suportados. Cada alvo descreve como detectá-lo no
|
|
19
25
|
// diretório-base, onde gravar (projeto vs. global) e em que formato converter
|
|
20
26
|
// o SKILL.md. Caminhos conferidos na doc oficial de cada ferramenta.
|
|
21
|
-
const TARGETS = [
|
|
27
|
+
export const TARGETS = [
|
|
22
28
|
{
|
|
23
29
|
key: 'claude',
|
|
24
30
|
name: 'Claude Code',
|
|
@@ -81,47 +87,59 @@ const TARGETS = [
|
|
|
81
87
|
const TARGET_BY_KEY = new Map(TARGETS.map((t) => [t.key, t]));
|
|
82
88
|
|
|
83
89
|
// Separa o frontmatter YAML do corpo do SKILL.md. Retorna { meta, body }.
|
|
84
|
-
function parseSkill(raw) {
|
|
90
|
+
export function parseSkill(raw) {
|
|
85
91
|
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
86
|
-
if (!match) return { meta: {}, body: raw.trim() };
|
|
92
|
+
if (!match) return { meta: {}, frontmatter: '', body: raw.trim() };
|
|
87
93
|
let meta = {};
|
|
88
94
|
try {
|
|
89
95
|
meta = yaml.load(match[1]) || {};
|
|
90
96
|
} catch {
|
|
91
97
|
meta = {};
|
|
92
98
|
}
|
|
93
|
-
return { meta, body: match[2].trim() };
|
|
99
|
+
return { meta, frontmatter: match[1], body: match[2].trim() };
|
|
94
100
|
}
|
|
95
101
|
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
102
|
+
// Banner de versão inserido no topo do corpo da skill instalada. O agente lê
|
|
103
|
+
// esta linha e, se `npx @spec-wave/cli --version` for maior, orienta reinstalar.
|
|
104
|
+
function versionBanner(version) {
|
|
105
|
+
return (
|
|
106
|
+
`> ⚙️ **spec-wave skill v${version}** — esta skill é uma cópia estática. ` +
|
|
107
|
+
'Se `npx @spec-wave/cli --version` indicar uma versão maior, ela está ' +
|
|
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).'
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Converte o SKILL.md para o formato exigido por cada agente, carimbando a versão.
|
|
114
|
+
export function renderContent(format, parsed, version) {
|
|
115
|
+
const { meta, frontmatter, body } = parsed;
|
|
99
116
|
const description = meta.description ?? 'Skill spec-wave.';
|
|
117
|
+
const banner = versionBanner(version);
|
|
100
118
|
switch (format) {
|
|
101
119
|
case 'skill':
|
|
102
|
-
// Claude Code / opencode consomem o SKILL.md nativo.
|
|
103
|
-
//
|
|
104
|
-
return
|
|
120
|
+
// Claude Code / opencode consomem o SKILL.md nativo. Preserva o frontmatter
|
|
121
|
+
// original (allowed-tools etc.) e insere o banner no topo do corpo.
|
|
122
|
+
return `---\n${frontmatter}\n---\n\n${banner}\n\n${body}\n`;
|
|
105
123
|
case 'mdc':
|
|
106
124
|
return (
|
|
107
125
|
`---\n` +
|
|
108
126
|
`description: ${JSON.stringify(description)}\n` +
|
|
109
127
|
`alwaysApply: false\n` +
|
|
110
128
|
`---\n\n` +
|
|
111
|
-
`${body}\n`
|
|
129
|
+
`${banner}\n\n${body}\n`
|
|
112
130
|
);
|
|
113
131
|
case 'rules':
|
|
114
|
-
return `# spec-wave\n\n${description}\n\n${body}\n`;
|
|
132
|
+
return `# spec-wave\n\n${banner}\n\n${description}\n\n${body}\n`;
|
|
115
133
|
case 'agents':
|
|
116
|
-
return `${BLOCK_START}\n\n# spec-wave\n\n${description}\n\n${body}\n\n${BLOCK_END}\n`;
|
|
134
|
+
return `${BLOCK_START}\n\n# spec-wave\n\n${banner}\n\n${description}\n\n${body}\n\n${BLOCK_END}\n`;
|
|
117
135
|
default:
|
|
118
|
-
return
|
|
136
|
+
return body;
|
|
119
137
|
}
|
|
120
138
|
}
|
|
121
139
|
|
|
122
140
|
// Insere/atualiza o bloco spec-wave num arquivo compartilhado (AGENTS.md),
|
|
123
141
|
// preservando o restante do conteúdo. Idempotente via marcadores.
|
|
124
|
-
function mergeAgentsFile(destPath, block) {
|
|
142
|
+
export function mergeAgentsFile(destPath, block) {
|
|
125
143
|
const existing = existsSync(destPath) ? readFileSync(destPath, 'utf-8') : '';
|
|
126
144
|
const blockRe = new RegExp(
|
|
127
145
|
`${escapeRe(BLOCK_START)}[\\s\\S]*?${escapeRe(BLOCK_END)}\\n?`,
|
|
@@ -137,9 +155,18 @@ function escapeRe(s) {
|
|
|
137
155
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
138
156
|
}
|
|
139
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
|
+
|
|
140
167
|
// Resolve o alvo do agente para um destino concreto no escopo escolhido.
|
|
141
168
|
// Retorna null quando o agente não suporta o escopo global.
|
|
142
|
-
function resolveDest(target, baseDir, isGlobal) {
|
|
169
|
+
export function resolveDest(target, baseDir, isGlobal) {
|
|
143
170
|
const rel = isGlobal ? target.global : target.project;
|
|
144
171
|
if (!rel) return null;
|
|
145
172
|
const format = isGlobal && target.globalFormat ? target.globalFormat : target.format;
|
|
@@ -147,7 +174,7 @@ function resolveDest(target, baseDir, isGlobal) {
|
|
|
147
174
|
}
|
|
148
175
|
|
|
149
176
|
// Retorna true se algum dos sinais de detecção existir em baseDir.
|
|
150
|
-
function isDetected(target, baseDir) {
|
|
177
|
+
export function isDetected(target, baseDir) {
|
|
151
178
|
return target.detect.some((sig) => existsSync(path.join(baseDir, sig)));
|
|
152
179
|
}
|
|
153
180
|
|
|
@@ -258,8 +285,8 @@ export async function installSkill(options = {}) {
|
|
|
258
285
|
for (const { target, dest } of jobs) {
|
|
259
286
|
const content =
|
|
260
287
|
dest.format === 'agents'
|
|
261
|
-
? mergeAgentsFile(dest.path, renderContent('agents',
|
|
262
|
-
: renderContent(dest.format,
|
|
288
|
+
? mergeAgentsFile(dest.path, renderContent('agents', parsed, pkg.version))
|
|
289
|
+
: renderContent(dest.format, parsed, pkg.version);
|
|
263
290
|
|
|
264
291
|
// Confirmar sobrescrita de arquivos "próprios" (skill/rules/mdc). Para
|
|
265
292
|
// 'agents' o merge por marcadores já é seguro (não apaga conteúdo alheio).
|
|
@@ -286,7 +313,10 @@ export async function installSkill(options = {}) {
|
|
|
286
313
|
|
|
287
314
|
p.note(
|
|
288
315
|
written.map((w) => `${chalk.green('✓')} ${chalk.bold(w.target.name)}\n ${chalk.dim(w.dest.path)}`).join('\n'),
|
|
289
|
-
`Skill instalada (escopo: ${scopeLabel})`,
|
|
316
|
+
`Skill v${pkg.version} instalada (escopo: ${scopeLabel})`,
|
|
317
|
+
);
|
|
318
|
+
p.outro(
|
|
319
|
+
'Reinicie/recarregue o agente para que ele detecte a skill. ' +
|
|
320
|
+
'Ao atualizar a CLI, rode `install-skill --force` para atualizar a skill também.',
|
|
290
321
|
);
|
|
291
|
-
p.outro('Reinicie/recarregue o agente para que ele detecte a skill.');
|
|
292
322
|
}
|