@spec-wave/cli 0.13.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.
@@ -16,6 +16,7 @@ import { parseDependencies, orderStories, formatDependencyLine } from '../lib/de
16
16
  import { loadProjectConfig, resolveField } from '../lib/board.mjs';
17
17
  import { planBoardMoves, applyBoardMoves } from '../lib/implement-board.mjs';
18
18
  import { extractPathsFromPlan, buildCodeDigest } from '../lib/code-digest.mjs';
19
+ import { findConfigPath, resolveFromRoot } from '../lib/project-root.mjs';
19
20
 
20
21
  // Diretório onde montamos o arquivo de contexto entregue ao spec-kit.
21
22
  const WORK_DIR = '.spec-wave';
@@ -359,7 +360,7 @@ function renderCommand(template, vars) {
359
360
  // Modo Feature: avalia as Stories da Feature (dependências + Etapa no board),
360
361
  // pula as já implementadas (Code Review+) e monta UM contexto único com todas
361
362
  // as pendentes em ordem topológica — spec-kit acionado uma vez.
362
- async function implementFeature({ token, owner, repo, config, feature, featureDirOpt, dryRun }) {
363
+ async function implementFeature({ token, owner, repo, config, feature, featureDirOpt, dryRun, repoRoot }) {
363
364
  // F1. Stories (sub-issues) da Feature.
364
365
  const subs = await listSubIssues(token, feature.node_id).catch(() => []);
365
366
  const stories = subs.filter(s => detectIssueType({ title: s.title, labels: s.labels }) === 'Story');
@@ -374,7 +375,7 @@ async function implementFeature({ token, owner, repo, config, feature, featureDi
374
375
  p.log.info(`Feature com ${stories.length} story(ies): ${stories.map(s => `#${s.number}`).join(', ')}`);
375
376
 
376
377
  // F1-board. Feature → Desenvolvimento (In Progress) já no início.
377
- await applyBoardMoves({ token, moves: planBoardMoves('start', {
378
+ await applyBoardMoves({ token, dryRun, moves: planBoardMoves('start', {
378
379
  feature: { nodeId: feature.node_id, number: feature.number },
379
380
  }) });
380
381
 
@@ -391,7 +392,15 @@ async function implementFeature({ token, owner, repo, config, feature, featureDi
391
392
  // F3. Etapa de cada Story no board (best-effort — sem board, nada é pulado).
392
393
  const { project, error: projectError } = loadProjectConfig();
393
394
  const stageOf = new Map();
394
- 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) {
395
404
  p.log.warn(`${projectError} — Etapas do board não consultadas; nenhuma Story será considerada implementada.`);
396
405
  } else {
397
406
  const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
@@ -451,7 +460,8 @@ async function implementFeature({ token, owner, repo, config, feature, featureDi
451
460
  }
452
461
 
453
462
  // F6. spec.md/plan.md — a issue-alvo JÁ é a Feature (sem resolveFeature).
454
- const featureDir = featureDirOpt || path.join('docs', 'features', slugify(feature.title));
463
+ const featureDir = featureDirOpt
464
+ || resolveFromRoot(repoRoot, 'docs', 'features', slugify(feature.title));
455
465
  let specPlan = { spec: null, plan: null, specPath: null, planPath: null };
456
466
  if (existsSync(featureDir)) {
457
467
  specPlan = readSpecPlan(featureDir);
@@ -556,12 +566,15 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
556
566
  }
557
567
 
558
568
  // 1. Config local (.spec-wave.json) — owner/repo e bloco opcional specKit.
559
- const configPath = path.join(process.cwd(), CONFIG_FILE);
560
- 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) {
561
573
  p.log.error(`Repositório não inicializado (sem ${CONFIG_FILE}). Rode \`spec-wave init\` primeiro.`);
562
574
  process.exitCode = 1;
563
575
  return;
564
576
  }
577
+ const repoRoot = path.dirname(configPath);
565
578
  let config;
566
579
  try {
567
580
  config = JSON.parse(readFileSync(configPath, 'utf-8'));
@@ -615,7 +628,7 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
615
628
  p.log.info(`Task única #${issueNumber}.`);
616
629
  } else if (type === 'Feature') {
617
630
  // Modo Feature: Stories pendentes em ordem de dependência, contexto único.
618
- await implementFeature({ token, owner, repo, config, feature: issue, featureDirOpt, dryRun });
631
+ await implementFeature({ token, owner, repo, config, feature: issue, featureDirOpt, dryRun, repoRoot });
619
632
  return;
620
633
  } else {
621
634
  p.log.error(
@@ -630,7 +643,9 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
630
643
  const feature = await resolveFeature(token, issue.node_id);
631
644
  let featureDir = featureDirOpt;
632
645
  if (!featureDir && feature?.title) {
633
- 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));
634
649
  }
635
650
  let specPlan = { spec: null, plan: null, specPath: null, planPath: null };
636
651
  if (featureDir && existsSync(featureDir)) {
@@ -644,7 +659,7 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
644
659
  // 4a-board. Board determinístico: Feature/Story → Desenvolvimento (In
645
660
  // Progress) já no início — a UI mostra o desenvolvimento em andamento sem
646
661
  // depender de o LLM mover cards. Best-effort: falha vira warn.
647
- await applyBoardMoves({ token, moves: planBoardMoves('start', {
662
+ await applyBoardMoves({ token, dryRun, moves: planBoardMoves('start', {
648
663
  feature: feature ? { nodeId: feature.nodeId, number: feature.number } : null,
649
664
  story: type === 'Story' ? { nodeId: issue.node_id, number: issue.number } : null,
650
665
  tasks: tasks.map(t => ({ nodeId: t.nodeId, number: t.number })),
@@ -753,6 +768,21 @@ async function writeContextAndRunSpecKit({ config, issueNumber, type, title, spe
753
768
  title,
754
769
  };
755
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
+
756
786
  if (!template) {
757
787
  p.log.warn('Comando do spec-kit não configurado.');
758
788
  p.note(
@@ -768,12 +798,6 @@ async function writeContextAndRunSpecKit({ config, issueNumber, type, title, spe
768
798
 
769
799
  const command = renderCommand(template, vars);
770
800
 
771
- if (dryRun) {
772
- p.note(command, 'Comando que seria executado (--dry-run)');
773
- p.outro(`Dry-run: nada executado. Contexto em ${tasksFile}.`);
774
- return;
775
- }
776
-
777
801
  p.log.step(`Executando: ${chalk.dim(command)}`);
778
802
  try {
779
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;