@spec-wave/cli 0.15.0 → 0.16.0
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 +1 -0
- package/bin/spec-wave.mjs +41 -2
- package/package.json +8 -2
- package/src/agent/anthropic-agent.mjs +337 -0
- package/src/agent/errors.mjs +33 -0
- package/src/agent/index.mjs +108 -0
- package/src/agent/openrouter-agent.mjs +378 -0
- package/src/agent/run-types.mjs +59 -0
- package/src/agent/telemetry.mjs +54 -0
- package/src/agent/tools.mjs +452 -0
- package/src/agent/tracing.mjs +106 -0
- package/src/api/github-rest.mjs +8 -0
- package/src/commands/bug.mjs +8 -0
- package/src/commands/code-review.mjs +45 -4
- package/src/commands/decompose.mjs +11 -49
- package/src/commands/dev-agent.mjs +3 -3
- package/src/commands/doctor.mjs +77 -6
- package/src/commands/generate-bug.mjs +195 -0
- package/src/commands/generate-plan.mjs +6 -20
- package/src/commands/generate-spec.mjs +6 -22
- package/src/commands/implement.mjs +105 -2
- package/src/commands/init.mjs +3 -3
- package/src/commands/install-skill.mjs +72 -16
- package/src/commands/issue.mjs +9 -7
- package/src/commands/move.mjs +11 -1
- package/src/commands/qa.mjs +23 -2
- package/src/commands/refresh.mjs +145 -5
- package/src/commands/triage.mjs +174 -0
- package/src/commands/update.mjs +16 -3
- package/src/commands/validate.mjs +82 -10
- package/src/config.mjs +159 -1
- package/src/lib/bug-context.mjs +160 -0
- package/src/lib/bug-doc.mjs +51 -0
- package/src/lib/bug-triage.mjs +81 -0
- package/src/lib/claude.mjs +71 -254
- package/src/lib/critique.mjs +43 -30
- package/src/lib/implement-board.mjs +12 -1
- package/src/lib/plugin-skills.mjs +122 -0
- package/src/lib/prompt-loader.mjs +257 -0
- package/src/lib/skill-file.mjs +35 -0
- package/src/plugin/.claude-plugin/plugin.json +20 -0
- package/src/plugin/README.md +73 -0
- package/src/plugin/skills/bug/SKILL.md +60 -0
- package/src/plugin/skills/bug/model-prompt.critique.md +48 -0
- package/src/plugin/skills/bug/model-prompt.md +74 -0
- package/src/plugin/skills/decompose/SKILL.md +111 -0
- package/src/plugin/skills/decompose/model-prompt.critique.md +46 -0
- package/src/plugin/skills/decompose/model-prompt.feature.md +69 -0
- package/src/plugin/skills/decompose/model-prompt.rfc.md +52 -0
- package/src/plugin/skills/doctor/SKILL.md +51 -0
- package/src/plugin/skills/fix-pr/SKILL.md +130 -0
- package/src/plugin/skills/implement/SKILL.md +102 -0
- package/src/plugin/skills/info/SKILL.md +40 -0
- package/src/plugin/skills/issue/SKILL.md +63 -0
- package/src/plugin/skills/move/SKILL.md +52 -0
- package/src/plugin/skills/order/SKILL.md +36 -0
- package/src/plugin/skills/plan/SKILL.md +53 -0
- package/src/plugin/skills/plan/model-prompt.critique.md +44 -0
- package/src/plugin/skills/plan/model-prompt.md +59 -0
- package/src/plugin/skills/plan/reference/tech-context.md +56 -0
- package/src/plugin/skills/ready/SKILL.md +44 -0
- package/src/plugin/skills/rfc/SKILL.md +47 -0
- package/src/plugin/skills/setup/SKILL.md +67 -0
- package/src/plugin/skills/spec/SKILL.md +37 -0
- package/src/plugin/skills/spec/model-prompt.md +61 -0
- package/src/plugin/skills/story/SKILL.md +49 -0
- package/src/plugin/skills/task/SKILL.md +41 -0
- package/src/plugin/skills/triage/SKILL.md +52 -0
- package/src/plugin/skills/uninstall/SKILL.md +43 -0
- package/src/plugin/skills/update/SKILL.md +51 -0
- package/src/plugin/skills/workflow/SKILL.md +154 -0
- package/src/templates/skill/SKILL.md +54 -4
- package/src/templates/workflows/generate-bug.yml +36 -0
- package/src/templates/workflows/validate.yml +2 -1
- package/src/ui/wizard.mjs +5 -2
|
@@ -6,11 +6,13 @@ import path from 'node:path';
|
|
|
6
6
|
import { resolveToken } from '../api/auth.mjs';
|
|
7
7
|
import {
|
|
8
8
|
CONFIG_FILE, STAGE_DEVELOPMENT, STAGE_CODE_REVIEW, STAGE_DONE, STAGE_ORDER,
|
|
9
|
-
PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE,
|
|
9
|
+
PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE, labelNames,
|
|
10
10
|
} from '../config.mjs';
|
|
11
11
|
import { getIssue, listIssueComments, listBlockedBy } from '../api/github-rest.mjs';
|
|
12
12
|
import { listSubIssues, getIssueParent, addProjectItem, getItemSingleSelectValue } from '../api/github-graphql.mjs';
|
|
13
13
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
14
|
+
import { bugDocPaths } from '../lib/bug-doc.mjs';
|
|
15
|
+
import { buildBugContext } from '../lib/bug-context.mjs';
|
|
14
16
|
import { slugify } from '../lib/slugify.mjs';
|
|
15
17
|
import { parseDependencies, orderStories, formatDependencyLine } from '../lib/dependencies.mjs';
|
|
16
18
|
import { loadProjectConfig, resolveField } from '../lib/board.mjs';
|
|
@@ -360,6 +362,102 @@ function renderCommand(template, vars) {
|
|
|
360
362
|
// Modo Feature: avalia as Stories da Feature (dependências + Etapa no board),
|
|
361
363
|
// pula as já implementadas (Code Review+) e monta UM contexto único com todas
|
|
362
364
|
// as pendentes em ordem topológica — spec-kit acionado uma vez.
|
|
365
|
+
/**
|
|
366
|
+
* Modo Bug (RFC-004 §7.1): sem tasks, sem spec/plan, sem Feature-pai a arrastar.
|
|
367
|
+
*
|
|
368
|
+
* O bug.md, quando existe, entra como HIPÓTESE — foi escrito por IA sem
|
|
369
|
+
* executar código, e o contexto diz isso explicitamente ao executor. Quando não
|
|
370
|
+
* existe (o caso do P0, que dispensa o documento), o contexto assume a
|
|
371
|
+
* investigação inteira.
|
|
372
|
+
*/
|
|
373
|
+
async function implementBug({ token, owner, repo, config, bug, dryRun, repoRoot }) {
|
|
374
|
+
const issueNumber = bug.number;
|
|
375
|
+
const severity = (labelNames(bug).find(n => /^P[0-3]$/.test(n))) || null;
|
|
376
|
+
|
|
377
|
+
// Item afetado (best-effort): dá ao executor o contexto do que quebrou. O pai
|
|
378
|
+
// de um Bug pode ser Feature OU Story — o tipo vem do prefixo do título.
|
|
379
|
+
let parent = null;
|
|
380
|
+
try {
|
|
381
|
+
const p0 = await getIssueParent(token, bug.node_id);
|
|
382
|
+
if (p0?.number) {
|
|
383
|
+
parent = { number: p0.number, title: p0.title, kind: detectIssueType(p0) || 'Item' };
|
|
384
|
+
}
|
|
385
|
+
} catch {
|
|
386
|
+
// sem pai legível — bug órfão é caso previsto (reporte de suporte)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// bug.md do repositório, se já foi gerado.
|
|
390
|
+
const { fileAbs, fileRel } = bugDocPaths(bug.title, repoRoot);
|
|
391
|
+
let bugDoc = null;
|
|
392
|
+
if (existsSync(fileAbs)) {
|
|
393
|
+
bugDoc = readFileSync(fileAbs, 'utf-8');
|
|
394
|
+
p.log.info(`bug.md encontrado em ${chalk.cyan(fileRel)}.`);
|
|
395
|
+
} else {
|
|
396
|
+
p.log.warn(`Sem bug.md em ${fileRel} — o contexto assume a investigação inteira.`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Board: Bug → Desenvolvimento (In Progress). Não move a Feature-pai.
|
|
400
|
+
await applyBoardMoves({
|
|
401
|
+
token,
|
|
402
|
+
moves: planBoardMoves('start', { bug: { nodeId: bug.node_id, number: issueNumber } }),
|
|
403
|
+
cwd: repoRoot || process.cwd(),
|
|
404
|
+
dryRun,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const comments = [];
|
|
408
|
+
const all = await listIssueComments(token, owner, repo, issueNumber).catch(() => []);
|
|
409
|
+
if (all.length > 0) {
|
|
410
|
+
const items = all.slice(-MAX_COMMENTS_PER_ISSUE).map(c => ({
|
|
411
|
+
...c,
|
|
412
|
+
body: c.body.length > MAX_COMMENT_CHARS
|
|
413
|
+
? `${c.body.slice(0, MAX_COMMENT_CHARS)}…[truncado]`
|
|
414
|
+
: c.body,
|
|
415
|
+
}));
|
|
416
|
+
comments.push({ issueNumber, kind: 'Bug', total: all.length, items });
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
let codeDigest = null;
|
|
420
|
+
try {
|
|
421
|
+
codeDigest = await buildCodeDigest({ sinceIso: bug.created_at || null, paths: [] });
|
|
422
|
+
} catch {
|
|
423
|
+
codeDigest = null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const blockedByWarnings = [];
|
|
427
|
+
try {
|
|
428
|
+
const blocked = await listBlockedBy(token, owner, repo, issueNumber).catch(() => []);
|
|
429
|
+
for (const b of blocked) {
|
|
430
|
+
if (b?.state !== 'closed') blockedByWarnings.push(`#${b.number} — ${b.title}`);
|
|
431
|
+
}
|
|
432
|
+
} catch {
|
|
433
|
+
// dependências não legíveis — segue
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const context = buildBugContext({
|
|
437
|
+
bug: { number: issueNumber, title: bug.title, body: bug.body || '' },
|
|
438
|
+
bugDoc, parent, comments, codeDigest, blockedByWarnings, severity,
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
await writeContextAndRunSpecKit({
|
|
442
|
+
config,
|
|
443
|
+
issueNumber,
|
|
444
|
+
type: 'Bug',
|
|
445
|
+
title: bug.title,
|
|
446
|
+
specPlan: { spec: null, plan: null, specPath: null, planPath: null },
|
|
447
|
+
context,
|
|
448
|
+
dryRun,
|
|
449
|
+
outroSuccess: `Bug #${issueNumber} corrigido — abra o PR com \`Fixes #${issueNumber}\`.`,
|
|
450
|
+
onSuccess: async () => {
|
|
451
|
+
await applyBoardMoves({
|
|
452
|
+
token,
|
|
453
|
+
moves: planBoardMoves('success', { bug: { nodeId: bug.node_id, number: issueNumber } }),
|
|
454
|
+
cwd: repoRoot || process.cwd(),
|
|
455
|
+
dryRun,
|
|
456
|
+
});
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
363
461
|
async function implementFeature({ token, owner, repo, config, feature, featureDirOpt, dryRun, repoRoot }) {
|
|
364
462
|
// F1. Stories (sub-issues) da Feature.
|
|
365
463
|
const subs = await listSubIssues(token, feature.node_id).catch(() => []);
|
|
@@ -630,9 +728,14 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
|
|
|
630
728
|
// Modo Feature: Stories pendentes em ordem de dependência, contexto único.
|
|
631
729
|
await implementFeature({ token, owner, repo, config, feature: issue, featureDirOpt, dryRun, repoRoot });
|
|
632
730
|
return;
|
|
731
|
+
} else if (type === 'Bug') {
|
|
732
|
+
// Modo Bug: sem tasks e sem spec/plan — o trabalho é investigar antes de
|
|
733
|
+
// corrigir, e o contexto impõe essa ordem.
|
|
734
|
+
await implementBug({ token, owner, repo, config, bug: issue, dryRun, repoRoot });
|
|
735
|
+
return;
|
|
633
736
|
} else {
|
|
634
737
|
p.log.error(
|
|
635
|
-
`implement
|
|
738
|
+
`implement aceita Feature, Story, Task ou Bug. Issue #${issueNumber} é do tipo ${type || 'desconhecido'}.`
|
|
636
739
|
);
|
|
637
740
|
process.exitCode = 1;
|
|
638
741
|
return;
|
package/src/commands/init.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { setupProject } from '../setup/project.mjs';
|
|
|
9
9
|
import { setupLabels } from '../setup/labels.mjs';
|
|
10
10
|
import { setupFiles } from '../setup/files.mjs';
|
|
11
11
|
import { getFileContent } from '../api/github-rest.mjs';
|
|
12
|
-
import { CONFIG_FILE, AI_PROVIDERS, getProvider, DEFAULT_PROVIDER, PORTAL_URL, WORKFLOW_FILES, ISSUE_TEMPLATE_FILES } from '../config.mjs';
|
|
12
|
+
import { CONFIG_FILE, AI_PROVIDERS, getProvider, DEFAULT_PROVIDER, PORTAL_URL, WORKFLOW_FILES, ISSUE_TEMPLATE_FILES, STATUS_OPTIONS, ALL_LABELS } 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'));
|
|
@@ -143,7 +143,7 @@ export async function init(options) {
|
|
|
143
143
|
labelSpinner.start('Criando labels...');
|
|
144
144
|
try {
|
|
145
145
|
await setupLabels(token, owner, repo, labelSpinner);
|
|
146
|
-
labelSpinner.stop(
|
|
146
|
+
labelSpinner.stop(`Labels criadas (${ALL_LABELS.length} labels)`);
|
|
147
147
|
} catch (err) {
|
|
148
148
|
labelSpinner.stop('');
|
|
149
149
|
p.log.error(`Erro ao criar labels: ${err.message}`);
|
|
@@ -204,7 +204,7 @@ export async function init(options) {
|
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
p.note(
|
|
207
|
-
|
|
207
|
+
`As ${STATUS_OPTIONS.length} colunas do RFC-001 foram criadas no campo "Etapa".\n` +
|
|
208
208
|
'Para usá-las como colunas do board:\n' +
|
|
209
209
|
' 1. Abra o projeto no GitHub\n' +
|
|
210
210
|
' 2. Clique em "..." → "Settings" da view de Board\n' +
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import * as p from '@clack/prompts';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import yaml from 'js-yaml';
|
|
4
3
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
5
4
|
import { fileURLToPath } from 'node:url';
|
|
6
5
|
import { homedir } from 'node:os';
|
|
7
6
|
import path from 'node:path';
|
|
7
|
+
import { parseSkill } from '../lib/skill-file.mjs';
|
|
8
|
+
import { listPluginSkills, planPluginSkillFiles } from '../lib/plugin-skills.mjs';
|
|
8
9
|
|
|
9
10
|
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
10
11
|
// Fonte única da skill, publicada via "files": ["src"] no package.json.
|
|
@@ -33,6 +34,17 @@ export const TARGETS = [
|
|
|
33
34
|
project: '.claude/skills/spec-wave/SKILL.md',
|
|
34
35
|
global: '.claude/skills/spec-wave/SKILL.md',
|
|
35
36
|
},
|
|
37
|
+
{
|
|
38
|
+
key: 'codex',
|
|
39
|
+
name: 'Codex CLI',
|
|
40
|
+
// Codex não tem conceito de plugin: ele varre diretórios de skills. Então o
|
|
41
|
+
// MESMO conteúdo do plugin é copiado skill a skill para `.agents/skills`,
|
|
42
|
+
// que é o caminho que o Codex lê no repo (e `~/.agents/skills` no usuário).
|
|
43
|
+
format: 'skills-dir',
|
|
44
|
+
detect: ['.codex', '.agents'],
|
|
45
|
+
project: path.join('.agents', 'skills'),
|
|
46
|
+
global: path.join('.agents', 'skills'),
|
|
47
|
+
},
|
|
36
48
|
{
|
|
37
49
|
key: 'opencode',
|
|
38
50
|
name: 'opencode',
|
|
@@ -86,22 +98,14 @@ export const TARGETS = [
|
|
|
86
98
|
|
|
87
99
|
const TARGET_BY_KEY = new Map(TARGETS.map((t) => [t.key, t]));
|
|
88
100
|
|
|
89
|
-
// Separa o frontmatter YAML do corpo do SKILL.md.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
let meta = {};
|
|
94
|
-
try {
|
|
95
|
-
meta = yaml.load(match[1]) || {};
|
|
96
|
-
} catch {
|
|
97
|
-
meta = {};
|
|
98
|
-
}
|
|
99
|
-
return { meta, frontmatter: match[1], body: match[2].trim() };
|
|
100
|
-
}
|
|
101
|
+
// Separa o frontmatter YAML do corpo do SKILL.md. Implementação em
|
|
102
|
+
// `lib/skill-file.mjs` (compartilhada com o loader das skills de IA);
|
|
103
|
+
// re-exportada aqui porque `update.mjs` e os testes a importam deste módulo.
|
|
104
|
+
export { parseSkill };
|
|
101
105
|
|
|
102
106
|
// Banner de versão inserido no topo do corpo da skill instalada. O agente lê
|
|
103
107
|
// esta linha e, se `npx @spec-wave/cli@latest --version` for maior, orienta reinstalar.
|
|
104
|
-
function versionBanner(version) {
|
|
108
|
+
export function versionBanner(version) {
|
|
105
109
|
return (
|
|
106
110
|
`> ⚙️ **spec-wave skill v${version}** — esta skill é uma cópia estática. ` +
|
|
107
111
|
'Se `npx @spec-wave/cli@latest --version` indicar uma versão maior, ela está ' +
|
|
@@ -182,6 +186,20 @@ export function isDetected(target, baseDir) {
|
|
|
182
186
|
// 'ausente' | 'bloco ausente' | 'desatualizada' — ou null se está em dia com a
|
|
183
187
|
// versão empacotada na CLI. Compartilhado entre `update` e `info`.
|
|
184
188
|
export function skillCopyReason(dest, parsed) {
|
|
189
|
+
// `skills-dir` não é um arquivo só: o destino é um diretório com uma pasta
|
|
190
|
+
// por skill. Diretório inteiro ausente conta como 'ausente'; qualquer arquivo
|
|
191
|
+
// faltando ou divergente conta como 'desatualizada' (é o mesmo remédio —
|
|
192
|
+
// reinstalar —, então não vale distinguir os dois casos).
|
|
193
|
+
if (dest.format === 'skills-dir') {
|
|
194
|
+
if (!existsSync(dest.path)) return 'ausente';
|
|
195
|
+
const files = planPluginSkillFiles(dest.path, CLI_VERSION, versionBanner);
|
|
196
|
+
if (!files.length) return null;
|
|
197
|
+
const stale = files.some(
|
|
198
|
+
(f) => !existsSync(f.path) || readFileSync(f.path, 'utf-8') !== f.content,
|
|
199
|
+
);
|
|
200
|
+
return stale ? 'desatualizada' : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
185
203
|
const desired = renderContent(dest.format, parsed, CLI_VERSION);
|
|
186
204
|
const existing = existsSync(dest.path) ? readFileSync(dest.path, 'utf-8') : null;
|
|
187
205
|
if (existing === null) return 'ausente';
|
|
@@ -312,7 +330,12 @@ export async function installSkill(options = {}) {
|
|
|
312
330
|
if (options.dryRun) {
|
|
313
331
|
p.note(
|
|
314
332
|
jobs
|
|
315
|
-
.map((j) =>
|
|
333
|
+
.map((j) => {
|
|
334
|
+
const head = `${chalk.dim(j.target.name.padEnd(20))} ${j.dest.path} ${chalk.dim(`(${j.dest.format})`)}`;
|
|
335
|
+
if (j.dest.format !== 'skills-dir') return head;
|
|
336
|
+
const names = listPluginSkills().map((s) => s.name);
|
|
337
|
+
return `${head}\n${chalk.dim(` ${names.length} skills: ${names.join(', ')}`)}`;
|
|
338
|
+
})
|
|
316
339
|
.join('\n'),
|
|
317
340
|
`Dry-run — nada será gravado (escopo: ${scopeLabel})`,
|
|
318
341
|
);
|
|
@@ -323,6 +346,34 @@ export async function installSkill(options = {}) {
|
|
|
323
346
|
// 4) Gravar cada destino.
|
|
324
347
|
const written = [];
|
|
325
348
|
for (const { target, dest } of jobs) {
|
|
349
|
+
// `skills-dir` grava N arquivos (uma pasta por skill do plugin) em vez de
|
|
350
|
+
// um. Sobrescrever aqui é seguro sem confirmar por arquivo: o destino é um
|
|
351
|
+
// diretório inteiro do spec-wave, não um arquivo que o usuário mantém.
|
|
352
|
+
if (dest.format === 'skills-dir') {
|
|
353
|
+
const files = planPluginSkillFiles(dest.path, pkg.version, versionBanner);
|
|
354
|
+
if (!files.length) {
|
|
355
|
+
p.log.error(`${target.name}: nenhuma skill encontrada no plugin empacotado — pulado.`);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (existsSync(dest.path) && !options.force && !options.yes) {
|
|
359
|
+
const ok = await p.confirm({
|
|
360
|
+
message: `${target.name}: ${dest.path} já existe. Sobrescrever as skills do spec-wave?`,
|
|
361
|
+
initialValue: true,
|
|
362
|
+
});
|
|
363
|
+
if (p.isCancel(ok) || !ok) {
|
|
364
|
+
p.log.info(`${target.name}: mantido (não sobrescrito).`);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
for (const file of files) {
|
|
369
|
+
mkdirSync(path.dirname(file.path), { recursive: true });
|
|
370
|
+
writeFileSync(file.path, file.content, 'utf-8');
|
|
371
|
+
}
|
|
372
|
+
const count = new Set(files.map((f) => f.skill)).size;
|
|
373
|
+
written.push({ target, dest, count });
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
326
377
|
const content =
|
|
327
378
|
dest.format === 'agents'
|
|
328
379
|
? mergeAgentsFile(dest.path, renderContent('agents', parsed, pkg.version))
|
|
@@ -352,7 +403,12 @@ export async function installSkill(options = {}) {
|
|
|
352
403
|
}
|
|
353
404
|
|
|
354
405
|
p.note(
|
|
355
|
-
written
|
|
406
|
+
written
|
|
407
|
+
.map((w) => {
|
|
408
|
+
const suffix = w.count ? chalk.dim(` (${w.count} skills)`) : '';
|
|
409
|
+
return `${chalk.green('✓')} ${chalk.bold(w.target.name)}${suffix}\n ${chalk.dim(w.dest.path)}`;
|
|
410
|
+
})
|
|
411
|
+
.join('\n'),
|
|
356
412
|
`Skill v${pkg.version} instalada (escopo: ${scopeLabel})`,
|
|
357
413
|
);
|
|
358
414
|
p.outro(
|
package/src/commands/issue.mjs
CHANGED
|
@@ -2,13 +2,11 @@ import * as p from '@clack/prompts';
|
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { resolveToken } from '../api/auth.mjs';
|
|
5
|
-
import { CONFIG_FILE,
|
|
5
|
+
import { CONFIG_FILE, PRIORITY_LABELS, WORK_ITEM_TYPES, initialStageForType } from '../config.mjs';
|
|
6
6
|
import { findConfigPath } from '../lib/project-root.mjs';
|
|
7
7
|
import { createIssue, getIssue } from '../api/github-rest.mjs';
|
|
8
8
|
import { addProjectItem, setItemSingleSelect, getSingleSelectField, addSubIssue } from '../api/github-graphql.mjs';
|
|
9
9
|
|
|
10
|
-
// Etapa inicial de todo work item recém-criado (📥 Backlog).
|
|
11
|
-
const INITIAL_STAGE = STATUS_OPTIONS[0].name;
|
|
12
10
|
const VALID_PRIORITIES = PRIORITY_LABELS.map(l => l.name);
|
|
13
11
|
|
|
14
12
|
// Resolve o tipo informado (case-insensitive) para o nome canônico (ex.: "Feature").
|
|
@@ -160,18 +158,22 @@ export async function issue(options) {
|
|
|
160
158
|
return;
|
|
161
159
|
}
|
|
162
160
|
|
|
161
|
+
// Etapa de nascimento: Backlog para quase todo tipo, 🐞 Triagem para Bug
|
|
162
|
+
// (✅ Ready quando o Bug já nasce P0). Ver initialStageForType.
|
|
163
|
+
const initialStage = initialStageForType(type, options.priority || null);
|
|
164
|
+
|
|
163
165
|
const boardSpinner = p.spinner();
|
|
164
166
|
boardSpinner.start('Adicionando ao Project...');
|
|
165
167
|
try {
|
|
166
168
|
const itemId = await addProjectItem(token, project.id, created.nodeId);
|
|
167
169
|
|
|
168
|
-
const stageOk = await setField(token, project, itemId, 'Etapa',
|
|
170
|
+
const stageOk = await setField(token, project, itemId, 'Etapa', initialStage);
|
|
169
171
|
const typeOk = await setField(token, project, itemId, 'Work Item Type', type);
|
|
170
172
|
if (options.priority) await setField(token, project, itemId, 'Priority', options.priority);
|
|
171
173
|
if (options.area) await setField(token, project, itemId, 'Area', options.area);
|
|
172
174
|
|
|
173
|
-
boardSpinner.stop(`Adicionada ao Project${stageOk ? ` em "${
|
|
174
|
-
if (!stageOk) p.log.warn(`Não foi possível definir a Etapa "${
|
|
175
|
+
boardSpinner.stop(`Adicionada ao Project${stageOk ? ` em "${initialStage}"` : ''}.`);
|
|
176
|
+
if (!stageOk) p.log.warn(`Não foi possível definir a Etapa "${initialStage}" (campo não encontrado).`);
|
|
175
177
|
if (!typeOk) p.log.warn(`Não foi possível definir o Work Item Type "${type}" (campo não encontrado).`);
|
|
176
178
|
} catch (err) {
|
|
177
179
|
boardSpinner.stop('');
|
|
@@ -185,7 +187,7 @@ export async function issue(options) {
|
|
|
185
187
|
Epic: `Próximo: crie Features sob este Epic com \`spec-wave feature --parent ${created.number} --title "..."\`.`,
|
|
186
188
|
};
|
|
187
189
|
p.outro(
|
|
188
|
-
`${chalk.green('✓')} ${type} #${created.number} criado em "${
|
|
190
|
+
`${chalk.green('✓')} ${type} #${created.number} criado em "${initialStage}"${parentLine}.\n` +
|
|
189
191
|
` ${hints[type] || ''}`
|
|
190
192
|
);
|
|
191
193
|
}
|
package/src/commands/move.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { loadProjectConfig, resolveField, advanceToStage, resolveStageName } fro
|
|
|
20
20
|
import { loadConfig } from '../lib/project-root.mjs';
|
|
21
21
|
import {
|
|
22
22
|
CONFIG_FILE, PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE,
|
|
23
|
-
isManualStageType, MANUAL_STAGE_TYPES,
|
|
23
|
+
isManualStageType, MANUAL_STAGE_TYPES, isStageInTrack, STAGE_TRACKS,
|
|
24
24
|
} from '../config.mjs';
|
|
25
25
|
|
|
26
26
|
const PROGRESS_VALUES = [PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE];
|
|
@@ -116,6 +116,16 @@ export async function move({ issue: issueArg, stage: stageArg, status: statusArg
|
|
|
116
116
|
return;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
// Aviso, não bloqueio: a trilha por tipo é convenção (RFC-004 §7.1); a regra
|
|
120
|
+
// dura continua sendo "a Etapa só avança". Um caso legítimo fora da trilha
|
|
121
|
+
// (um Bug que o time decidiu homologar) não deve exigir escape hatch.
|
|
122
|
+
if (type && !isStageInTrack(type, stage)) {
|
|
123
|
+
p.log.warn(
|
|
124
|
+
`${type} normalmente não passa por ${stage} — a trilha do tipo é: ` +
|
|
125
|
+
`${STAGE_TRACKS[type].join(' → ')}.`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
119
129
|
const { project, error: projectError } = loadProjectConfig({ cwd: root || process.cwd() });
|
|
120
130
|
if (projectError) {
|
|
121
131
|
p.log.error(`${projectError} — board não atualizado. Rode \`spec-wave init\` (ou \`spec-wave refresh --config\`).`);
|
package/src/commands/qa.mjs
CHANGED
|
@@ -78,6 +78,27 @@ export async function qa({ prNumber }) {
|
|
|
78
78
|
const updated = [];
|
|
79
79
|
|
|
80
80
|
for (const num of issueNums) {
|
|
81
|
+
// Bug é unidade própria: vai para 🧪 QA sozinho, sem arrastar a Feature-pai
|
|
82
|
+
// (que pode ter Stories ainda em desenvolvimento).
|
|
83
|
+
const issue = await getIssue(token, owner, repo, num).catch(() => null);
|
|
84
|
+
if (issue && detectIssueType(issue) === 'Bug') {
|
|
85
|
+
if (seen.has(issue.number)) continue;
|
|
86
|
+
seen.add(issue.number);
|
|
87
|
+
try {
|
|
88
|
+
const moved = await advanceToStage(
|
|
89
|
+
projectToken, project, etapaField, statusField, issue.node_id, QA_STAGE, TODO_STATUS);
|
|
90
|
+
if (moved) {
|
|
91
|
+
updated.push(`#${issue.number} ${issue.title} (bug)`);
|
|
92
|
+
console.log(`Bug #${issue.number} → "${QA_STAGE}" / Status "${TODO_STATUS}".`);
|
|
93
|
+
} else {
|
|
94
|
+
console.log(`Bug #${issue.number} já está em "${QA_STAGE}" ou etapa posterior — mantido.`);
|
|
95
|
+
}
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.warn(`Falha ao atualizar Bug #${issue.number}: ${err.message}`);
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
81
102
|
const feature = await resolveFeatureIssue(token, owner, repo, num);
|
|
82
103
|
if (!feature || seen.has(feature.number)) continue;
|
|
83
104
|
seen.add(feature.number);
|
|
@@ -99,10 +120,10 @@ export async function qa({ prNumber }) {
|
|
|
99
120
|
await commentOnIssue(
|
|
100
121
|
token, owner, repo, parseInt(prNumber, 10),
|
|
101
122
|
`🧪 **PR aprovado — QA iniciado**\n\n` +
|
|
102
|
-
`
|
|
123
|
+
`Item(ns) movido(s) para **${QA_STAGE}**:\n\n` +
|
|
103
124
|
updated.map(f => `- ${f}`).join('\n')
|
|
104
125
|
).catch(() => {});
|
|
105
126
|
}
|
|
106
127
|
|
|
107
|
-
console.log(`qa: ${updated.length}
|
|
128
|
+
console.log(`qa: ${updated.length} item(ns) atualizado(s).`);
|
|
108
129
|
}
|
package/src/commands/refresh.mjs
CHANGED
|
@@ -4,20 +4,148 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { resolveToken } from '../api/auth.mjs';
|
|
7
|
-
import { CONFIG_FILE } from '../config.mjs';
|
|
7
|
+
import { CONFIG_FILE, STATUS_OPTIONS } from '../config.mjs';
|
|
8
8
|
import { findConfigPath } from '../lib/project-root.mjs';
|
|
9
|
-
import { getProjectSnapshot } from '../api/github-graphql.mjs';
|
|
9
|
+
import { getProjectSnapshot, updateStatusField } from '../api/github-graphql.mjs';
|
|
10
10
|
|
|
11
11
|
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
12
12
|
const pkg = JSON.parse(readFileSync(path.join(__dir, '..', '..', 'package.json'), 'utf-8'));
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Plano de sincronização das opções do campo "Etapa" (função PURA).
|
|
16
|
+
*
|
|
17
|
+
* A operação é DELIBERADAMENTE aditiva: `ordered` contém tudo que já existe no
|
|
18
|
+
* board mais as etapas canônicas que faltam, cada uma na posição canônica. Nada
|
|
19
|
+
* é removido — nem coluna inventada, nem etapa descontinuada.
|
|
20
|
+
*
|
|
21
|
+
* O motivo é o contrato da API: `updateProjectV2Field` SUBSTITUI o conjunto de
|
|
22
|
+
* opções do single-select, e a preservação do valor de cada item do board
|
|
23
|
+
* depende de a opção continuar existindo com o mesmo nome. Uma opção que some
|
|
24
|
+
* leva junto a Etapa de todo item que estava nela — silenciosamente. Remover
|
|
25
|
+
* coluna continua sendo trabalho manual, feito com os itens já esvaziados.
|
|
26
|
+
*
|
|
27
|
+
* @param {string[]} current nomes das opções hoje no board, na ordem atual
|
|
28
|
+
* @param {Array<{name: string, color: string}>} canonical STATUS_OPTIONS
|
|
29
|
+
* @returns {{ missing: string[], preserved: string[], ordered: Array<{name: string, color: string}> }}
|
|
30
|
+
*/
|
|
31
|
+
export function planStageSync(current, canonical) {
|
|
32
|
+
const have = new Set(current || []);
|
|
33
|
+
const canonicalNames = new Set(canonical.map(o => o.name));
|
|
34
|
+
|
|
35
|
+
const missing = canonical.filter(o => !have.has(o.name)).map(o => o.name);
|
|
36
|
+
// Colunas que o board tem e o fluxo canônico não conhece (inventadas ou
|
|
37
|
+
// descontinuadas). Vão para o fim, preservando a ordem relativa que tinham.
|
|
38
|
+
const preserved = (current || []).filter(name => !canonicalNames.has(name));
|
|
39
|
+
|
|
40
|
+
const ordered = [
|
|
41
|
+
...canonical.map(o => ({ name: o.name, color: o.color })),
|
|
42
|
+
...preserved.map(name => ({ name, color: 'GRAY' })),
|
|
43
|
+
];
|
|
44
|
+
return { missing, preserved, ordered };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Aplica o plano de `planStageSync` no campo "Etapa" do Project.
|
|
49
|
+
*
|
|
50
|
+
* Escreve só quando há etapa canônica faltando, e verifica o resultado relendo
|
|
51
|
+
* o Project: se alguma opção que existia antes sumiu, grita — é o único jeito
|
|
52
|
+
* de o usuário descobrir a tempo que itens perderam a Etapa.
|
|
53
|
+
*
|
|
54
|
+
* @returns {Promise<boolean>} true se o comando deve continuar
|
|
55
|
+
*/
|
|
56
|
+
async function syncStages(token, snapshot, options) {
|
|
57
|
+
const etapa = snapshot.fields?.['Etapa'];
|
|
58
|
+
if (!etapa?.id) {
|
|
59
|
+
p.log.error('Campo "Etapa" não encontrado no Project — nada a sincronizar.');
|
|
60
|
+
process.exitCode = 1;
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const current = Object.keys(etapa.options || {});
|
|
65
|
+
const { missing, preserved, ordered } = planStageSync(current, STATUS_OPTIONS);
|
|
66
|
+
|
|
67
|
+
if (missing.length === 0) {
|
|
68
|
+
p.log.success(`Campo "Etapa" já tem as ${STATUS_OPTIONS.length} etapas canônicas.`);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
p.note(
|
|
73
|
+
`${chalk.green('+')} acrescentar: ${missing.join(', ')}\n` +
|
|
74
|
+
(preserved.length
|
|
75
|
+
? `${chalk.dim('=')} preservar (fora do fluxo canônico): ${preserved.join(', ')}\n`
|
|
76
|
+
: '') +
|
|
77
|
+
`${chalk.dim('=')} preservar (canônicas já presentes): ${current.length - preserved.length}\n\n` +
|
|
78
|
+
chalk.dim(`Resultado: ${ordered.length} opções. Nenhuma é removida.`),
|
|
79
|
+
'Plano para o campo "Etapa"'
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (options.dryRun) {
|
|
83
|
+
p.outro('Dry-run: nada foi enviado ao GitHub.');
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!options.yes) {
|
|
88
|
+
const go = await p.confirm({
|
|
89
|
+
message: 'Reescrever as opções do campo "Etapa" com esse conjunto?',
|
|
90
|
+
initialValue: false,
|
|
91
|
+
});
|
|
92
|
+
if (p.isCancel(go) || !go) {
|
|
93
|
+
p.outro('Cancelado — nada foi alterado.');
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const spinner = p.spinner();
|
|
99
|
+
spinner.start('Atualizando o campo "Etapa"...');
|
|
100
|
+
try {
|
|
101
|
+
await updateStatusField(token, etapa.id, ordered);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
spinner.stop('');
|
|
104
|
+
p.log.error(`Falha ao atualizar o campo "Etapa": ${err.message}`);
|
|
105
|
+
process.exitCode = 1;
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Verificação pós-escrita: releitura, não confiança na mutação.
|
|
110
|
+
let after;
|
|
111
|
+
try {
|
|
112
|
+
after = await getProjectSnapshot(token, snapshot.id);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
spinner.stop('');
|
|
115
|
+
p.log.warn(`Campo atualizado, mas a verificação falhou: ${err.message}`);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
const now = Object.keys(after?.fields?.['Etapa']?.options || {});
|
|
119
|
+
const lost = current.filter(name => !now.includes(name));
|
|
120
|
+
const stillMissing = STATUS_OPTIONS.map(o => o.name).filter(name => !now.includes(name));
|
|
121
|
+
spinner.stop(`Campo "Etapa" com ${now.length} opções.`);
|
|
122
|
+
|
|
123
|
+
if (lost.length > 0) {
|
|
124
|
+
p.log.error(
|
|
125
|
+
`Opções que existiam SUMIRAM: ${lost.join(', ')}. Os itens que estavam nelas ficaram ` +
|
|
126
|
+
'sem Etapa — recrie a coluna com o mesmo nome e reposicione os itens.'
|
|
127
|
+
);
|
|
128
|
+
process.exitCode = 1;
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
if (stillMissing.length > 0) {
|
|
132
|
+
p.log.warn(`Etapas canônicas ainda ausentes: ${stillMissing.join(', ')}.`);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
p.log.success(`Etapas acrescentadas: ${missing.join(', ')}.`);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
14
139
|
// Re-consulta o GitHub Project e reescreve o .spec-wave.json local com os dados
|
|
15
140
|
// atuais (id/number/url/title do Project, IDs do campo Etapa e das opções, e a
|
|
16
141
|
// versão da CLI). Útil para repositórios inicializados antes do enriquecimento,
|
|
17
142
|
// ou quando o Project foi renomeado/teve campos alterados.
|
|
18
143
|
export async function refresh(options = {}) {
|
|
19
|
-
if (!options.config) {
|
|
20
|
-
p.log.error(
|
|
144
|
+
if (!options.config && !options.stages) {
|
|
145
|
+
p.log.error(
|
|
146
|
+
'Nada a fazer. Use `spec-wave refresh --config` para atualizar o .spec-wave.json, ' +
|
|
147
|
+
'ou `--stages` para acrescentar as colunas canônicas que faltam no campo "Etapa".'
|
|
148
|
+
);
|
|
21
149
|
process.exitCode = 1;
|
|
22
150
|
return;
|
|
23
151
|
}
|
|
@@ -57,7 +185,7 @@ export async function refresh(options = {}) {
|
|
|
57
185
|
return;
|
|
58
186
|
}
|
|
59
187
|
|
|
60
|
-
p.intro(chalk.bold(
|
|
188
|
+
p.intro(chalk.bold(`spec-wave refresh${options.stages ? ' --stages' : ' --config'}`));
|
|
61
189
|
|
|
62
190
|
const spinner = p.spinner();
|
|
63
191
|
spinner.start('Consultando o GitHub Project...');
|
|
@@ -78,6 +206,18 @@ export async function refresh(options = {}) {
|
|
|
78
206
|
}
|
|
79
207
|
spinner.stop('Project consultado.');
|
|
80
208
|
|
|
209
|
+
if (options.stages) {
|
|
210
|
+
const ok = await syncStages(token, snapshot, options);
|
|
211
|
+
if (!ok) return;
|
|
212
|
+
// Depois de mexer no board, o .spec-wave.json guarda ids de opção velhos —
|
|
213
|
+
// relê o Project para que o resto do comando grave o estado novo.
|
|
214
|
+
snapshot = await getProjectSnapshot(token, projectId);
|
|
215
|
+
if (!options.config) {
|
|
216
|
+
p.outro('Campo "Etapa" sincronizado. Rode `spec-wave refresh --config` para atualizar o .spec-wave.json.');
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
81
221
|
// Remove campos legados (versões anteriores gravavam etapaFieldId/stageOptions soltos).
|
|
82
222
|
const { etapaFieldId: _e, stageOptions: _s, ...projectRest } = config.project;
|
|
83
223
|
const updated = {
|