@spec-wave/cli 0.12.0 → 0.14.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.
Files changed (40) hide show
  1. package/README.md +39 -27
  2. package/bin/spec-wave.mjs +14 -4
  3. package/package.json +1 -1
  4. package/src/api/github-graphql.mjs +0 -4
  5. package/src/api/github-rest.mjs +0 -13
  6. package/src/commands/code-review.mjs +5 -8
  7. package/src/commands/decompose.mjs +410 -251
  8. package/src/commands/dev-agent.mjs +3 -2
  9. package/src/commands/doctor.mjs +239 -9
  10. package/src/commands/generate-plan.mjs +111 -51
  11. package/src/commands/generate-spec.mjs +20 -22
  12. package/src/commands/implement.mjs +46 -24
  13. package/src/commands/info.mjs +4 -3
  14. package/src/commands/issue.mjs +4 -4
  15. package/src/commands/move.mjs +162 -0
  16. package/src/commands/order.mjs +1 -12
  17. package/src/commands/qa.mjs +5 -8
  18. package/src/commands/refresh.mjs +4 -3
  19. package/src/commands/story.mjs +1 -12
  20. package/src/commands/task.mjs +1 -11
  21. package/src/commands/update.mjs +43 -19
  22. package/src/commands/validate.mjs +47 -35
  23. package/src/config.mjs +40 -6
  24. package/src/lib/board.mjs +88 -26
  25. package/src/lib/claude.mjs +315 -70
  26. package/src/lib/critique.mjs +391 -91
  27. package/src/lib/decomposition-doc.mjs +451 -0
  28. package/src/lib/implement-board.mjs +14 -1
  29. package/src/lib/project-root.mjs +93 -0
  30. package/src/lib/templates.mjs +53 -0
  31. package/src/setup/files.mjs +3 -10
  32. package/src/templates/skill/SKILL.md +137 -61
  33. package/src/templates/workflows/code-review.yml +1 -1
  34. package/src/templates/workflows/decompose.yml +20 -6
  35. package/src/templates/workflows/generate-plan.yml +1 -1
  36. package/src/templates/workflows/generate-spec.yml +1 -1
  37. package/src/templates/workflows/qa.yml +1 -1
  38. package/src/templates/workflows/validate.yml +1 -1
  39. package/src/lib/feature-docs.mjs +0 -89
  40. package/src/lib/force.mjs +0 -34
@@ -14,9 +14,9 @@ import { detectIssueType } from '../lib/issue-type.mjs';
14
14
  import { slugify } from '../lib/slugify.mjs';
15
15
  import { parseDependencies, orderStories, formatDependencyLine } from '../lib/dependencies.mjs';
16
16
  import { loadProjectConfig, resolveField } from '../lib/board.mjs';
17
- import { resolveDoc } from '../lib/feature-docs.mjs';
18
17
  import { planBoardMoves, applyBoardMoves } from '../lib/implement-board.mjs';
19
18
  import { extractPathsFromPlan, buildCodeDigest } from '../lib/code-digest.mjs';
19
+ import { findConfigPath, resolveFromRoot } from '../lib/project-root.mjs';
20
20
 
21
21
  // Diretório onde montamos o arquivo de contexto entregue ao spec-kit.
22
22
  const WORK_DIR = '.spec-wave';
@@ -42,16 +42,15 @@ async function resolveFeature(token, startNodeId) {
42
42
  return null;
43
43
  }
44
44
 
45
- // Lê os documentos ATUAIS (maior versão) de um docs/features/<slug>: com
46
- // regerações forçadas o vigente é spec-v2.md/plan-v3.md…, não o original.
45
+ // Lê spec.md/plan.md de um docs/features/<slug> se existirem.
47
46
  function readSpecPlan(featureDir) {
48
- const spec = resolveDoc(featureDir, 'spec');
49
- const plan = resolveDoc(featureDir, 'plan');
47
+ const specPath = path.join(featureDir, 'spec.md');
48
+ const planPath = path.join(featureDir, 'plan.md');
50
49
  return {
51
- specPath: spec?.path || path.join(featureDir, 'spec.md'),
52
- planPath: plan?.path || path.join(featureDir, 'plan.md'),
53
- spec: spec ? readFileSync(spec.path, 'utf-8') : null,
54
- plan: plan ? readFileSync(plan.path, 'utf-8') : null,
50
+ specPath,
51
+ planPath,
52
+ spec: existsSync(specPath) ? readFileSync(specPath, 'utf-8') : null,
53
+ plan: existsSync(planPath) ? readFileSync(planPath, 'utf-8') : null,
55
54
  };
56
55
  }
57
56
 
@@ -361,7 +360,7 @@ function renderCommand(template, vars) {
361
360
  // Modo Feature: avalia as Stories da Feature (dependências + Etapa no board),
362
361
  // pula as já implementadas (Code Review+) e monta UM contexto único com todas
363
362
  // as pendentes em ordem topológica — spec-kit acionado uma vez.
364
- async function implementFeature({ token, owner, repo, config, feature, featureDirOpt, dryRun }) {
363
+ async function implementFeature({ token, owner, repo, config, feature, featureDirOpt, dryRun, repoRoot }) {
365
364
  // F1. Stories (sub-issues) da Feature.
366
365
  const subs = await listSubIssues(token, feature.node_id).catch(() => []);
367
366
  const stories = subs.filter(s => detectIssueType({ title: s.title, labels: s.labels }) === 'Story');
@@ -376,7 +375,7 @@ async function implementFeature({ token, owner, repo, config, feature, featureDi
376
375
  p.log.info(`Feature com ${stories.length} story(ies): ${stories.map(s => `#${s.number}`).join(', ')}`);
377
376
 
378
377
  // F1-board. Feature → Desenvolvimento (In Progress) já no início.
379
- await applyBoardMoves({ token, moves: planBoardMoves('start', {
378
+ await applyBoardMoves({ token, dryRun, moves: planBoardMoves('start', {
380
379
  feature: { nodeId: feature.node_id, number: feature.number },
381
380
  }) });
382
381
 
@@ -393,7 +392,15 @@ async function implementFeature({ token, owner, repo, config, feature, featureDi
393
392
  // F3. Etapa de cada Story no board (best-effort — sem board, nada é pulado).
394
393
  const { project, error: projectError } = loadProjectConfig();
395
394
  const stageOf = new Map();
396
- if (projectError) {
395
+ if (dryRun) {
396
+ // A leitura da Etapa passa por addProjectItem, que é MUTAÇÃO (adiciona a
397
+ // Story ao Project se ainda não estiver lá). Num dry-run isso é proibido, então
398
+ // a consulta é pulada inteira — o planejamento segue com stage null.
399
+ p.log.warn(
400
+ 'Dry-run: Etapas do board não consultadas (a consulta adicionaria itens ao Project); ' +
401
+ 'nenhuma Story será considerada implementada.'
402
+ );
403
+ } else if (projectError) {
397
404
  p.log.warn(`${projectError} — Etapas do board não consultadas; nenhuma Story será considerada implementada.`);
398
405
  } else {
399
406
  const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
@@ -453,7 +460,8 @@ async function implementFeature({ token, owner, repo, config, feature, featureDi
453
460
  }
454
461
 
455
462
  // F6. spec.md/plan.md — a issue-alvo JÁ é a Feature (sem resolveFeature).
456
- const featureDir = featureDirOpt || path.join('docs', 'features', slugify(feature.title));
463
+ const featureDir = featureDirOpt
464
+ || resolveFromRoot(repoRoot, 'docs', 'features', slugify(feature.title));
457
465
  let specPlan = { spec: null, plan: null, specPath: null, planPath: null };
458
466
  if (existsSync(featureDir)) {
459
467
  specPlan = readSpecPlan(featureDir);
@@ -558,12 +566,15 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
558
566
  }
559
567
 
560
568
  // 1. Config local (.spec-wave.json) — owner/repo e bloco opcional specKit.
561
- const configPath = path.join(process.cwd(), CONFIG_FILE);
562
- if (!existsSync(configPath)) {
569
+ // Procurado subindo na árvore, como o git faz com o .git: rodar de dentro de
570
+ // apps/web precisa achar o config da raiz. `repoRoot` ancora docs/features.
571
+ const configPath = findConfigPath();
572
+ if (!configPath) {
563
573
  p.log.error(`Repositório não inicializado (sem ${CONFIG_FILE}). Rode \`spec-wave init\` primeiro.`);
564
574
  process.exitCode = 1;
565
575
  return;
566
576
  }
577
+ const repoRoot = path.dirname(configPath);
567
578
  let config;
568
579
  try {
569
580
  config = JSON.parse(readFileSync(configPath, 'utf-8'));
@@ -617,7 +628,7 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
617
628
  p.log.info(`Task única #${issueNumber}.`);
618
629
  } else if (type === 'Feature') {
619
630
  // Modo Feature: Stories pendentes em ordem de dependência, contexto único.
620
- await implementFeature({ token, owner, repo, config, feature: issue, featureDirOpt, dryRun });
631
+ await implementFeature({ token, owner, repo, config, feature: issue, featureDirOpt, dryRun, repoRoot });
621
632
  return;
622
633
  } else {
623
634
  p.log.error(
@@ -632,7 +643,9 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
632
643
  const feature = await resolveFeature(token, issue.node_id);
633
644
  let featureDir = featureDirOpt;
634
645
  if (!featureDir && feature?.title) {
635
- featureDir = path.join('docs', 'features', slugify(feature.title));
646
+ // Ancorado na RAIZ do repo: o config é encontrado subindo na árvore, e os
647
+ // documentos precisam ser procurados no mesmo lugar.
648
+ featureDir = resolveFromRoot(repoRoot, 'docs', 'features', slugify(feature.title));
636
649
  }
637
650
  let specPlan = { spec: null, plan: null, specPath: null, planPath: null };
638
651
  if (featureDir && existsSync(featureDir)) {
@@ -646,7 +659,7 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
646
659
  // 4a-board. Board determinístico: Feature/Story → Desenvolvimento (In
647
660
  // Progress) já no início — a UI mostra o desenvolvimento em andamento sem
648
661
  // depender de o LLM mover cards. Best-effort: falha vira warn.
649
- await applyBoardMoves({ token, moves: planBoardMoves('start', {
662
+ await applyBoardMoves({ token, dryRun, moves: planBoardMoves('start', {
650
663
  feature: feature ? { nodeId: feature.nodeId, number: feature.number } : null,
651
664
  story: type === 'Story' ? { nodeId: issue.node_id, number: issue.number } : null,
652
665
  tasks: tasks.map(t => ({ nodeId: t.nodeId, number: t.number })),
@@ -755,6 +768,21 @@ async function writeContextAndRunSpecKit({ config, issueNumber, type, title, spe
755
768
  title,
756
769
  };
757
770
 
771
+ // O dry-run é decidido ANTES do early return de "spec-kit não configurado":
772
+ // sem isso, com o comando ausente os dois caminhos ficavam indistinguíveis e a
773
+ // saída não dizia que estava em dry-run.
774
+ if (dryRun) {
775
+ if (template) {
776
+ p.note(renderCommand(template, vars), 'Comando que seria executado (--dry-run)');
777
+ } else {
778
+ p.log.warn(`Comando do spec-kit não configurado em ${CONFIG_FILE} — nada a imprimir.`);
779
+ }
780
+ p.outro(
781
+ `Dry-run: nada executado e nada escrito no GitHub. Contexto montado em ${tasksFile}.`
782
+ );
783
+ return;
784
+ }
785
+
758
786
  if (!template) {
759
787
  p.log.warn('Comando do spec-kit não configurado.');
760
788
  p.note(
@@ -770,12 +798,6 @@ async function writeContextAndRunSpecKit({ config, issueNumber, type, title, spe
770
798
 
771
799
  const command = renderCommand(template, vars);
772
800
 
773
- if (dryRun) {
774
- p.note(command, 'Comando que seria executado (--dry-run)');
775
- p.outro(`Dry-run: nada executado. Contexto em ${tasksFile}.`);
776
- return;
777
- }
778
-
779
801
  p.log.step(`Executando: ${chalk.dim(command)}`);
780
802
  try {
781
803
  execSync(command, { stdio: 'inherit' });
@@ -1,8 +1,9 @@
1
1
  import * as p from '@clack/prompts';
2
2
  import chalk from 'chalk';
3
- import { readFileSync, existsSync } from 'node:fs';
3
+ import { readFileSync } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { CONFIG_FILE, PORTAL_URL } from '../config.mjs';
6
+ import { findConfigPath } from '../lib/project-root.mjs';
6
7
  import { skillStatus } from './install-skill.mjs';
7
8
 
8
9
  // Resume o estado da skill para a saída JSON. `null` = não foi possível checar.
@@ -43,10 +44,10 @@ function reportSkill(status) {
43
44
  // as informações ou oferecer rodar o `init`. Também valida se a skill instalada
44
45
  // nos agentes detectados está em dia com a versão empacotada na CLI.
45
46
  export async function info(options = {}) {
46
- const configPath = path.join(process.cwd(), CONFIG_FILE);
47
+ const configPath = findConfigPath();
47
48
  const skill = skillStatus();
48
49
 
49
- if (!existsSync(configPath)) {
50
+ if (!configPath) {
50
51
  if (options.json) {
51
52
  console.log(JSON.stringify({ initialized: false, skill: skillJson(skill) }));
52
53
  return;
@@ -1,9 +1,9 @@
1
1
  import * as p from '@clack/prompts';
2
2
  import chalk from 'chalk';
3
- import { readFileSync, existsSync } from 'node:fs';
4
- import path from 'node:path';
3
+ import { readFileSync } from 'node:fs';
5
4
  import { resolveToken } from '../api/auth.mjs';
6
5
  import { CONFIG_FILE, STATUS_OPTIONS, PRIORITY_LABELS, WORK_ITEM_TYPES } from '../config.mjs';
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
 
@@ -70,8 +70,8 @@ export async function issue(options) {
70
70
  return;
71
71
  }
72
72
 
73
- const configPath = path.join(process.cwd(), CONFIG_FILE);
74
- if (!existsSync(configPath)) {
73
+ const configPath = findConfigPath();
74
+ if (!configPath) {
75
75
  p.log.error(`Repositório não inicializado (sem ${CONFIG_FILE}). Rode \`spec-wave init\` primeiro.`);
76
76
  process.exitCode = 1;
77
77
  return;
@@ -0,0 +1,162 @@
1
+ // Move QUALQUER item do board para uma Etapa (Feature, Story, Task, Bug, RFC…).
2
+ //
3
+ // Existia um buraco no fluxo: `story` só aceita `review` e `task` só
4
+ // `start`/`done`, então mover uma Feature pela CLI era impossível — o único
5
+ // caminho era mutação GraphQL manual, justamente o que a skill instrui a evitar
6
+ // ("prefira sempre os comandos da CLI a mutações manuais no board").
7
+ //
8
+ // O comando se chama `move` e não `feature <ação>` porque `feature` já está
9
+ // registrado no bin CRIANDO issues; reaproveitar o nome colidiria.
10
+ //
11
+ // As guardas do board são as mesmas dos outros comandos — reusa advanceToStage,
12
+ // logo a Etapa NUNCA retrocede e não há escape hatch.
13
+ import * as p from '@clack/prompts';
14
+ import chalk from 'chalk';
15
+ import { resolveToken } from '../api/auth.mjs';
16
+ import { getIssue } from '../api/github-rest.mjs';
17
+ import { addProjectItem, getItemSingleSelectValue } from '../api/github-graphql.mjs';
18
+ import { detectIssueType } from '../lib/issue-type.mjs';
19
+ import { loadProjectConfig, resolveField, advanceToStage, resolveStageName } from '../lib/board.mjs';
20
+ import { loadConfig } from '../lib/project-root.mjs';
21
+ import {
22
+ CONFIG_FILE, PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE,
23
+ isManualStageType, MANUAL_STAGE_TYPES,
24
+ } from '../config.mjs';
25
+
26
+ const PROGRESS_VALUES = [PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE];
27
+
28
+ /**
29
+ * Resolve o valor do campo Status a partir da entrada do usuário (função PURA).
30
+ * Sem `--status`, o destino é "Todo": ao avançar de Etapa o progresso reinicia
31
+ * (ver a distinção Etapa × Status em config.mjs).
32
+ *
33
+ * @param {string} [input]
34
+ * @returns {{ status: string|null, error: string|null }}
35
+ */
36
+ export function resolveProgressName(input) {
37
+ if (input === undefined || input === null || String(input).trim() === '') {
38
+ return { status: PROGRESS_TODO, error: null };
39
+ }
40
+ const key = String(input).trim().toLowerCase();
41
+ const match = PROGRESS_VALUES.find(v => v.toLowerCase() === key)
42
+ || PROGRESS_VALUES.find(v => v.toLowerCase().replace(/\s+/g, '') === key.replace(/[\s_-]+/g, ''));
43
+ return match
44
+ ? { status: match, error: null }
45
+ : {
46
+ status: null,
47
+ error:
48
+ `Status "${input}" não existe. Use um destes: ${PROGRESS_VALUES.map(v => `"${v}"`).join(', ')}.`,
49
+ };
50
+ }
51
+
52
+ export async function move({ issue: issueArg, stage: stageArg, status: statusArg }) {
53
+ const issueNumber = parseInt(String(issueArg).replace('#', ''), 10);
54
+ if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
55
+ p.log.error(`Issue inválida: "${issueArg}". Use o número da issue, ex.: 12 ou #12.`);
56
+ process.exitCode = 1;
57
+ return;
58
+ }
59
+
60
+ const { stage, error: stageError } = resolveStageName(stageArg);
61
+ if (stageError) {
62
+ p.log.error(stageError);
63
+ process.exitCode = 1;
64
+ return;
65
+ }
66
+ const { status, error: statusError } = resolveProgressName(statusArg);
67
+ if (statusError) {
68
+ p.log.error(statusError);
69
+ process.exitCode = 1;
70
+ return;
71
+ }
72
+
73
+ // owner/repo: env GITHUB_REPOSITORY (padrão dos comandos de Action) com
74
+ // fallback no .spec-wave.json, procurado subindo na árvore.
75
+ const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
76
+ const { config, root } = loadConfig();
77
+ const owner = envOwner || config?.owner;
78
+ const repo = envRepo || config?.repo;
79
+ if (!owner || !repo) {
80
+ p.log.error(
81
+ 'Não foi possível determinar owner/repo.\n' +
82
+ `Rode dentro de um repositório com ${CONFIG_FILE} (\`spec-wave init\`) ou defina GITHUB_REPOSITORY=owner/repo.`
83
+ );
84
+ process.exitCode = 1;
85
+ return;
86
+ }
87
+
88
+ let token;
89
+ try {
90
+ token = await resolveToken();
91
+ } catch (err) {
92
+ p.log.error(err.message);
93
+ process.exitCode = 1;
94
+ return;
95
+ }
96
+
97
+ p.intro(chalk.bold(`spec-wave move #${issueNumber} → ${stage}`));
98
+
99
+ let issue;
100
+ try {
101
+ issue = await getIssue(token, owner, repo, issueNumber);
102
+ } catch (err) {
103
+ p.log.error(`Não foi possível ler a issue #${issueNumber}: ${err.message}`);
104
+ process.exitCode = 1;
105
+ return;
106
+ }
107
+
108
+ // Spike tem avanço decidido pelo usuário no board, não por comando.
109
+ const type = detectIssueType(issue);
110
+ if (isManualStageType(type)) {
111
+ p.log.error(
112
+ `Issue #${issueNumber} é do tipo ${type}, cuja Etapa é movida à mão no board ` +
113
+ `(tipos manuais: ${MANUAL_STAGE_TYPES.join(', ')}).`
114
+ );
115
+ process.exitCode = 1;
116
+ return;
117
+ }
118
+
119
+ const { project, error: projectError } = loadProjectConfig({ cwd: root || process.cwd() });
120
+ if (projectError) {
121
+ p.log.error(`${projectError} — board não atualizado. Rode \`spec-wave init\` (ou \`spec-wave refresh --config\`).`);
122
+ process.exitCode = 1;
123
+ return;
124
+ }
125
+ const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
126
+ const statusField = await resolveField(token, project, 'Status').catch(() => null);
127
+
128
+ let moved;
129
+ try {
130
+ moved = await advanceToStage(
131
+ token, project, etapaField, statusField, issue.node_id, stage, status);
132
+ } catch (err) {
133
+ p.log.error(`Falha ao atualizar o board: ${err.message}`);
134
+ process.exitCode = 1;
135
+ return;
136
+ }
137
+
138
+ if (moved) {
139
+ p.log.success(
140
+ `${type || 'Issue'} #${issueNumber} → Etapa ${chalk.bold(stage)} / Status ${chalk.bold(status)}.`
141
+ );
142
+ p.outro(`${chalk.green('✓')} ${issue.title}`);
143
+ return;
144
+ }
145
+
146
+ // false = já está nessa Etapa ou adiante (ou numa coluna fora da ordem
147
+ // canônica). A Etapa NUNCA retrocede — lê a atual só para informar.
148
+ let current = null;
149
+ if (etapaField?.id) {
150
+ try {
151
+ const itemId = await addProjectItem(token, project.id, issue.node_id);
152
+ current = await getItemSingleSelectValue(token, itemId, etapaField.id);
153
+ } catch {
154
+ // sem leitura da Etapa — segue com o aviso genérico
155
+ }
156
+ }
157
+ p.log.info(
158
+ `#${issueNumber} não foi movida: a Etapa nunca retrocede, e ela já está em ` +
159
+ `${chalk.bold(current || `"${stage}" ou etapa posterior`)}.`
160
+ );
161
+ p.outro('Nada a fazer.');
162
+ }
@@ -7,26 +7,15 @@
7
7
  // GITHUB_REPOSITORY quando existir, senão do .spec-wave.json (gravado pelo init).
8
8
  import * as p from '@clack/prompts';
9
9
  import chalk from 'chalk';
10
- import { existsSync, readFileSync } from 'node:fs';
11
- import path from 'node:path';
12
10
  import { resolveToken } from '../api/auth.mjs';
13
11
  import { getIssue, listBlockedBy } from '../api/github-rest.mjs';
14
12
  import { addProjectItem, listSubIssues, getItemSingleSelectValue } from '../api/github-graphql.mjs';
15
13
  import { detectIssueType } from '../lib/issue-type.mjs';
16
14
  import { parseDependencies, orderStories } from '../lib/dependencies.mjs';
17
15
  import { loadProjectConfig, resolveField } from '../lib/board.mjs';
16
+ import { resolveRepoContext } from '../lib/project-root.mjs';
18
17
  import { CONFIG_FILE, STAGE_ORDER, STAGE_DEVELOPMENT, STAGE_DONE } from '../config.mjs';
19
18
 
20
- // Resolve owner/repo: env GITHUB_REPOSITORY (padrão dos comandos de Action) com
21
- // fallback no .spec-wave.json — comandos locais rodam sem essa env.
22
- function resolveRepoContext() {
23
- const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
24
- let cfg = {};
25
- const cfgPath = path.join(process.cwd(), CONFIG_FILE);
26
- try { if (existsSync(cfgPath)) cfg = JSON.parse(readFileSync(cfgPath, 'utf-8')); } catch {}
27
- return { owner: envOwner || cfg.owner, repo: envRepo || cfg.repo };
28
- }
29
-
30
19
  export async function order({ feature: featureArg }) {
31
20
  const featureNumber = parseInt(String(featureArg).replace('#', ''), 10);
32
21
  if (!Number.isInteger(featureNumber) || featureNumber <= 0) {
@@ -1,11 +1,10 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import path from 'node:path';
3
1
  import { resolveToken } from '../api/auth.mjs';
4
2
  import { getIssue, getPR, commentOnIssue } from '../api/github-rest.mjs';
5
3
  import { getIssueParent } from '../api/github-graphql.mjs';
6
4
  import { detectIssueType } from '../lib/issue-type.mjs';
7
5
  import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
8
- import { CONFIG_FILE, STATUS_OPTIONS, PROGRESS_TODO } from '../config.mjs';
6
+ import { loadConfig } from '../lib/project-root.mjs';
7
+ import { STATUS_OPTIONS, PROGRESS_TODO } from '../config.mjs';
9
8
 
10
9
  const QA_STAGE = STATUS_OPTIONS.find(s => s.name.includes('QA'))?.name;
11
10
  const TODO_STATUS = PROGRESS_TODO;
@@ -47,11 +46,9 @@ export async function qa({ prNumber }) {
47
46
  const token = await resolveToken();
48
47
  const projectToken = process.env.PROJECT_TOKEN || token;
49
48
  const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
50
- const cfgPath = path.join(process.cwd(), CONFIG_FILE);
51
- let cfg = {};
52
- try { if (existsSync(cfgPath)) cfg = JSON.parse(readFileSync(cfgPath, 'utf-8')); } catch {}
53
- const owner = envOwner || cfg.owner;
54
- const repo = envRepo || cfg.repo;
49
+ const { config: cfg } = loadConfig();
50
+ const owner = envOwner || cfg?.owner;
51
+ const repo = envRepo || cfg?.repo;
55
52
 
56
53
  if (!owner || !repo) {
57
54
  throw new Error(
@@ -1,10 +1,11 @@
1
1
  import * as p from '@clack/prompts';
2
2
  import chalk from 'chalk';
3
- import { readFileSync, writeFileSync, existsSync } from 'node:fs';
3
+ 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
7
  import { CONFIG_FILE } from '../config.mjs';
8
+ import { findConfigPath } from '../lib/project-root.mjs';
8
9
  import { getProjectSnapshot } from '../api/github-graphql.mjs';
9
10
 
10
11
  const __dir = path.dirname(fileURLToPath(import.meta.url));
@@ -21,8 +22,8 @@ export async function refresh(options = {}) {
21
22
  return;
22
23
  }
23
24
 
24
- const configPath = path.join(process.cwd(), CONFIG_FILE);
25
- if (!existsSync(configPath)) {
25
+ const configPath = findConfigPath();
26
+ if (!configPath) {
26
27
  p.log.error(`Repositório não inicializado (sem ${CONFIG_FILE}). Rode \`spec-wave init\` primeiro.`);
27
28
  process.exitCode = 1;
28
29
  return;
@@ -7,25 +7,14 @@
7
7
  // estiver em Code Review ou adiante, o comando apenas informa a Etapa atual.
8
8
  import * as p from '@clack/prompts';
9
9
  import chalk from 'chalk';
10
- import { existsSync, readFileSync } from 'node:fs';
11
- import path from 'node:path';
12
10
  import { resolveToken } from '../api/auth.mjs';
13
11
  import { getIssue } from '../api/github-rest.mjs';
14
12
  import { addProjectItem, getItemSingleSelectValue } from '../api/github-graphql.mjs';
15
13
  import { detectIssueType } from '../lib/issue-type.mjs';
16
14
  import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
15
+ import { resolveRepoContext } from '../lib/project-root.mjs';
17
16
  import { CONFIG_FILE, STAGE_CODE_REVIEW, PROGRESS_TODO } from '../config.mjs';
18
17
 
19
- // Resolve owner/repo: env GITHUB_REPOSITORY (padrão dos comandos de Action) com
20
- // fallback no .spec-wave.json — comandos locais rodam sem essa env.
21
- function resolveRepoContext() {
22
- const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
23
- let cfg = {};
24
- const cfgPath = path.join(process.cwd(), CONFIG_FILE);
25
- try { if (existsSync(cfgPath)) cfg = JSON.parse(readFileSync(cfgPath, 'utf-8')); } catch {}
26
- return { owner: envOwner || cfg.owner, repo: envRepo || cfg.repo };
27
- }
28
-
29
18
  export async function story({ action, issue: issueArg }) {
30
19
  if (action !== 'review') {
31
20
  p.log.error(
@@ -10,13 +10,12 @@
10
10
  // (regra pura canStartTask, testada em test/board-rules.test.mjs).
11
11
  import * as p from '@clack/prompts';
12
12
  import chalk from 'chalk';
13
- import { existsSync, readFileSync } from 'node:fs';
14
- import path from 'node:path';
15
13
  import { resolveToken } from '../api/auth.mjs';
16
14
  import { getIssue } from '../api/github-rest.mjs';
17
15
  import { addProjectItem, getIssueParent, listSubIssues, getItemSingleSelectValue } from '../api/github-graphql.mjs';
18
16
  import { detectIssueType } from '../lib/issue-type.mjs';
19
17
  import { loadProjectConfig, resolveField, advanceToStage, setItemStatus } from '../lib/board.mjs';
18
+ import { resolveRepoContext } from '../lib/project-root.mjs';
20
19
  import {
21
20
  CONFIG_FILE, STAGE_DEVELOPMENT, STAGE_DONE,
22
21
  PROGRESS_IN_PROGRESS, PROGRESS_DONE,
@@ -36,15 +35,6 @@ export function canStartTask({ siblings } = {}) {
36
35
  return busy ? { ok: false, blocker: busy.number } : { ok: true, blocker: null };
37
36
  }
38
37
 
39
- // Resolve owner/repo: env GITHUB_REPOSITORY (padrão dos comandos de Action) com
40
- // fallback no .spec-wave.json — comandos locais rodam sem essa env.
41
- function resolveRepoContext() {
42
- const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
43
- let cfg = {};
44
- const cfgPath = path.join(process.cwd(), CONFIG_FILE);
45
- try { if (existsSync(cfgPath)) cfg = JSON.parse(readFileSync(cfgPath, 'utf-8')); } catch {}
46
- return { owner: envOwner || cfg.owner, repo: envRepo || cfg.repo };
47
- }
48
38
 
49
39
  export async function task({ action, issue: issueArg }) {
50
40
  if (action !== 'start' && action !== 'done') {
@@ -1,25 +1,23 @@
1
1
  import * as p from '@clack/prompts';
2
2
  import chalk from 'chalk';
3
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
4
- import { fileURLToPath } from 'node:url';
3
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
5
4
  import { homedir } from 'node:os';
6
5
  import path from 'node:path';
7
6
  import { resolveToken } from '../api/auth.mjs';
8
7
  import { CONFIG_FILE, WORKFLOW_FILES, ISSUE_TEMPLATE_FILES, ALL_LABELS } from '../config.mjs';
9
8
  import { getProjectSnapshot } from '../api/github-graphql.mjs';
10
9
  import {
11
- getFileContent, upsertFile, listLabels, createLabel, updateLabel,
10
+ getFileContent, upsertFile, listLabels, createLabel, updateLabel, deleteLabel,
12
11
  } from '../api/github-rest.mjs';
12
+ // MESMO readTemplate do init: resolve {{CLI_VERSION}} antes da comparação byte a
13
+ // byte com o remoto. Se só o init resolvesse, todo update veria os workflows
14
+ // como desatualizados para sempre.
15
+ import { readTemplate } from '../lib/templates.mjs';
13
16
  import {
14
17
  TARGETS, SKILL_SOURCE, CLI_VERSION, parseSkill, renderContent,
15
18
  mergeAgentsFile, resolveDest, isDetected, skillCopyReason,
16
19
  } 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
- }
20
+ import { findConfigPath } from '../lib/project-root.mjs';
23
21
 
24
22
  // Arquivos do repo gerenciados pela CLI (comparados com o template empacotado).
25
23
  const REPO_FILES = [
@@ -52,10 +50,23 @@ function applySkill(job) {
52
50
  writeFileSync(job.dest.path, content, 'utf-8');
53
51
  }
54
52
 
55
- // Compara ALL_LABELS com as labels do repo. color no config é hex maiúsculo; a
56
- // API retorna minúsculo daí o toLowerCase() na comparação.
57
- function diffLabels(existing) {
58
- const byName = new Map(existing.map(l => [l.name, l]));
53
+ /**
54
+ * Compara ALL_LABELS com as labels do repo (função PURA — testável).
55
+ *
56
+ * `color` no config é hex maiúsculo e a API retorna minúsculo — daí o
57
+ * toLowerCase() na comparação.
58
+ *
59
+ * `orphan` são labels `spec-wave:*` que existem no repo mas saíram do config: o
60
+ * update só sabia criar e atualizar, nunca remover, então uma label descontinuada
61
+ * ficava para sempre — foi o caso da `spec-wave:force`, removida do código na
62
+ * 0.13.0 mas ainda presente em todo repo inicializado com a 0.12.0. Só o
63
+ * namespace `spec-wave:` é considerado: labels do time não são da nossa conta.
64
+ *
65
+ * @param {Array<{name,color,description}>} existing labels do repo
66
+ * @returns {{ missing: object[], changed: object[], orphan: object[] }}
67
+ */
68
+ export function diffLabels(existing) {
69
+ const byName = new Map((existing || []).map(l => [l.name, l]));
59
70
  const missing = [];
60
71
  const changed = [];
61
72
  for (const label of ALL_LABELS) {
@@ -63,13 +74,15 @@ function diffLabels(existing) {
63
74
  if (!cur) {
64
75
  missing.push(label);
65
76
  } else if (
66
- cur.color.toLowerCase() !== label.color.toLowerCase() ||
77
+ (cur.color || '').toLowerCase() !== label.color.toLowerCase() ||
67
78
  (cur.description || '') !== (label.description || '')
68
79
  ) {
69
80
  changed.push(label);
70
81
  }
71
82
  }
72
- return { missing, changed };
83
+ const known = new Set(ALL_LABELS.map(l => l.name));
84
+ const orphan = (existing || []).filter(l => l.name?.startsWith('spec-wave:') && !known.has(l.name));
85
+ return { missing, changed, orphan };
73
86
  }
74
87
 
75
88
  export async function update(options = {}) {
@@ -84,9 +97,9 @@ export async function update(options = {}) {
84
97
  const skillJobs = options.skipSkill ? [] : detectSkill(parsed, baseDir, isGlobal);
85
98
 
86
99
  // 2) Config + repo dependem do .spec-wave.json local do repo atual.
87
- const configPath = path.join(process.cwd(), CONFIG_FILE);
100
+ const configPath = findConfigPath();
88
101
  let config = null;
89
- if (existsSync(configPath)) {
102
+ if (configPath) {
90
103
  try {
91
104
  config = JSON.parse(readFileSync(configPath, 'utf-8'));
92
105
  } catch (err) {
@@ -124,7 +137,7 @@ export async function update(options = {}) {
124
137
 
125
138
  // 2b) Arquivos do repo e labels divergentes (exige token + rede).
126
139
  let repoFiles = [];
127
- let labelDiff = { missing: [], changed: [] };
140
+ let labelDiff = { missing: [], changed: [], orphan: [] };
128
141
  let repoChecked = false;
129
142
  if (doRepo) {
130
143
  const s = p.spinner();
@@ -153,7 +166,7 @@ export async function update(options = {}) {
153
166
  }
154
167
 
155
168
  // ---------- Resumo ----------
156
- const labelTotal = labelDiff.missing.length + labelDiff.changed.length;
169
+ const labelTotal = labelDiff.missing.length + labelDiff.changed.length + labelDiff.orphan.length;
157
170
  const total = skillJobs.length + (configStale ? 1 : 0) + repoFiles.length + labelTotal;
158
171
 
159
172
  if (total === 0) {
@@ -180,6 +193,9 @@ export async function update(options = {}) {
180
193
  lines.push(chalk.bold('Labels:'));
181
194
  if (labelDiff.missing.length) lines.push(` ${chalk.yellow('+')} criar: ${labelDiff.missing.map(l => l.name).join(', ')}`);
182
195
  if (labelDiff.changed.length) lines.push(` ${chalk.yellow('↻')} atualizar: ${labelDiff.changed.map(l => l.name).join(', ')}`);
196
+ if (labelDiff.orphan.length) {
197
+ lines.push(` ${chalk.red('−')} remover (descontinuadas): ${labelDiff.orphan.map(l => l.name).join(', ')}`);
198
+ }
183
199
  }
184
200
  p.note(lines.join('\n'), `${total} item(ns) desatualizado(s)`);
185
201
 
@@ -279,6 +295,14 @@ export async function update(options = {}) {
279
295
  p.log.error(`Falha ao atualizar label ${label.name}: ${err.message}`);
280
296
  }
281
297
  }
298
+ for (const label of labelDiff.orphan) {
299
+ try {
300
+ await deleteLabel(tk, owner, repo, label.name);
301
+ p.log.success(`Label descontinuada removida: ${label.name}`);
302
+ } catch (err) {
303
+ p.log.error(`Falha ao remover label ${label.name}: ${err.message}`);
304
+ }
305
+ }
282
306
  }
283
307
 
284
308
  const committedRepo = repoFiles.length > 0;