@spec-wave/cli 0.27.0 → 0.29.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/package.json +1 -1
- package/src/api/github-graphql.mjs +37 -0
- package/src/api/github-rest.mjs +48 -0
- package/src/cli.mjs +51 -2
- package/src/commands/audit.mjs +280 -0
- package/src/commands/doctor.mjs +40 -16
- package/src/commands/implement.mjs +19 -2
- package/src/commands/install-skill.mjs +18 -8
- package/src/commands/merge.mjs +292 -0
- package/src/commands/move.mjs +26 -11
- package/src/commands/order.mjs +42 -0
- package/src/commands/preflight.mjs +322 -0
- package/src/commands/run.mjs +4 -3
- package/src/commands/update.mjs +143 -12
- package/src/lib/board.mjs +18 -2
- package/src/lib/critique.mjs +64 -8
- package/src/lib/pr-branch.mjs +96 -7
- package/src/lib/pr-step.mjs +12 -7
- package/src/lib/spec-audit.mjs +372 -0
- package/src/lib/tech-context.mjs +20 -14
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/README.md +5 -0
- package/src/plugin/skills/audit/SKILL.md +34 -0
- package/src/plugin/skills/audit/model-prompt.critique.md +34 -0
- package/src/plugin/skills/merge/SKILL.md +34 -0
- package/src/plugin/skills/order/SKILL.md +1 -0
- package/src/plugin/skills/plan/model-prompt.md +1 -0
- package/src/plugin/skills/plan/reference/tech-context.md +6 -0
- package/src/plugin/skills/preparar-feature/SKILL.md +247 -0
- package/src/plugin/skills/preparar-feature/reference/critica.md +88 -0
- package/src/plugin/skills/preparar-specs/SKILL.md +191 -0
- package/src/plugin/skills/preparar-specs/reference/armadilhas.md +209 -0
- package/src/plugin/skills/preparar-specs/reference/revisao.md +110 -0
- package/src/plugin/skills/update/SKILL.md +10 -4
- package/src/plugin/skills/workflow/SKILL.md +6 -1
- package/src/templates/config/tech_context.yml +13 -0
- package/src/templates/skill/SKILL.md +36 -7
- package/src/templates/workflows/qa.yml +9 -1
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// Preflight de uma rodada de geração de specs por milestone.
|
|
2
|
+
//
|
|
3
|
+
// Existe porque **cada geração paga um modelo**: descobrir na sétima Feature que
|
|
4
|
+
// a credencial estava errada custa sete gerações. Este comando levanta de uma
|
|
5
|
+
// vez tudo que decide a estratégia da rodada e tudo que costuma fazê-la falhar
|
|
6
|
+
// no fim — antes de gerar qualquer coisa.
|
|
7
|
+
//
|
|
8
|
+
// Ele NÃO é um segundo `doctor`. As verificações de ambiente reaproveitam os
|
|
9
|
+
// mesmos inspetores puros que o doctor usa (`describeModeState`,
|
|
10
|
+
// `inspectPrPublishing`); o que é novo aqui é o **inventário da milestone**:
|
|
11
|
+
// quais Features existem, e onde está o `spec.md` de cada uma.
|
|
12
|
+
//
|
|
13
|
+
// E "onde está" é a parte que não dá para improvisar com `existsSync`. Um
|
|
14
|
+
// documento recém-gerado vive numa branch `spec-wave/<n>-spec` que ninguém
|
|
15
|
+
// mergeou: quem procura só no disco o declara ausente e manda gerar de novo —
|
|
16
|
+
// pagando o modelo pela segunda vez pelo mesmo documento. Por isso o estado sai
|
|
17
|
+
// de `artifactStates`, que enxerga as quatro camadas.
|
|
18
|
+
|
|
19
|
+
import * as p from '@clack/prompts';
|
|
20
|
+
import chalk from 'chalk';
|
|
21
|
+
|
|
22
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
23
|
+
import {
|
|
24
|
+
getRepoDefaultBranch, getRepoVariable, listMilestones, listIssuesByMilestone,
|
|
25
|
+
} from '../api/github-rest.mjs';
|
|
26
|
+
import { CONFIG_FILE } from '../config.mjs';
|
|
27
|
+
import { inspectPrPublishing, readPrPublishingContext } from './doctor.mjs';
|
|
28
|
+
import { configuredMode, describeModeState, EXECUTION_VARIABLE } from '../lib/execution-mode.mjs';
|
|
29
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
30
|
+
import { isAwaitingMerge, artifactStates } from '../lib/doc-source.mjs';
|
|
31
|
+
import { featureDocPaths } from '../lib/doc-paths.mjs';
|
|
32
|
+
import { STEPS } from '../lib/next-step.mjs';
|
|
33
|
+
import { loadConfig } from '../lib/project-root.mjs';
|
|
34
|
+
import { unguardedWorkflows } from './mode.mjs';
|
|
35
|
+
|
|
36
|
+
const TRIGGER_LABELS = Object.values(STEPS).map(s => s.trigger).filter(Boolean);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* O inventário da milestone (função PURA — é onde mora a decisão).
|
|
40
|
+
*
|
|
41
|
+
* Separa as Features em quatro destinos porque cada um pede uma ação diferente,
|
|
42
|
+
* e confundi-los é caro nos dois sentidos: tratar `pending-pr` como ausente
|
|
43
|
+
* regera um documento já pago e ainda descarta a revisão em curso; tratar
|
|
44
|
+
* `unknown` (falha de rede) como pronto pula uma Feature que ninguém escreveu.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} [a]
|
|
47
|
+
* @param {Array<{number:number,title:string,state?:string,labels?:Array}>} [a.issues]
|
|
48
|
+
* issues da milestone, como a API devolve (de qualquer tipo)
|
|
49
|
+
* @param {Record<number, {state: string, pr: object|null}>} [a.specStates]
|
|
50
|
+
* estado do `spec.md` por número de issue
|
|
51
|
+
* @returns {{ features: object[], gerar: object[], prontas: object[],
|
|
52
|
+
* aguardandoMerge: object[], indefinidas: object[], fechadas: object[],
|
|
53
|
+
* gatilhosPendentes: Array<{number:number,labels:string[]}> }}
|
|
54
|
+
*/
|
|
55
|
+
export function inspectMilestone({ issues = [], specStates = {} } = {}) {
|
|
56
|
+
const features = (issues || [])
|
|
57
|
+
.filter(i => detectIssueType(i) === 'Feature')
|
|
58
|
+
.map(i => {
|
|
59
|
+
const { state = 'unknown', pr = null } = specStates[i.number] || {};
|
|
60
|
+
return {
|
|
61
|
+
number: i.number,
|
|
62
|
+
title: i.title,
|
|
63
|
+
closed: i.state === 'closed',
|
|
64
|
+
spec: state,
|
|
65
|
+
pr,
|
|
66
|
+
labels: (i.labels || []).map(l => (typeof l === 'string' ? l : l?.name)).filter(Boolean),
|
|
67
|
+
};
|
|
68
|
+
})
|
|
69
|
+
.sort((a, b) => a.number - b.number);
|
|
70
|
+
|
|
71
|
+
const abertas = features.filter(f => !f.closed);
|
|
72
|
+
return {
|
|
73
|
+
features,
|
|
74
|
+
fechadas: features.filter(f => f.closed),
|
|
75
|
+
gerar: abertas.filter(f => f.spec === 'missing'),
|
|
76
|
+
prontas: abertas.filter(f => f.spec === 'local' || f.spec === 'remote'),
|
|
77
|
+
aguardandoMerge: abertas.filter(f => isAwaitingMerge(f.spec)),
|
|
78
|
+
indefinidas: abertas.filter(f => f.spec === 'unknown'),
|
|
79
|
+
// Label de gatilho grudada significa Action em execução — ou uma que falhou e
|
|
80
|
+
// a deixou para trás. Nos dois casos o `run` se recusa a executar (portão
|
|
81
|
+
// `trigger-pending`), então a rodada travaria Feature a Feature.
|
|
82
|
+
gatilhosPendentes: features
|
|
83
|
+
.map(f => ({ number: f.number, labels: f.labels.filter(l => TRIGGER_LABELS.includes(l)) }))
|
|
84
|
+
.filter(f => f.labels.length > 0),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve o título da milestone para o número que a API de issues aceita (PURA).
|
|
90
|
+
*
|
|
91
|
+
* Casamento exato primeiro; sem ele, um único casamento sem diferenciar
|
|
92
|
+
* maiúsculas. Ambíguo é erro, não escolha silenciosa: gerar as specs da
|
|
93
|
+
* milestone errada custa uma geração por Feature.
|
|
94
|
+
*
|
|
95
|
+
* @param {Array<{number:number,title:string,state?:string}>} milestones
|
|
96
|
+
* @param {string} titulo
|
|
97
|
+
* @returns {{ milestone: object|null, error: string|null }}
|
|
98
|
+
*/
|
|
99
|
+
export function resolveMilestone(milestones = [], titulo = '') {
|
|
100
|
+
const alvo = String(titulo || '').trim();
|
|
101
|
+
const lista = () => (milestones.length
|
|
102
|
+
? milestones.map(m => `"${m.title}"`).join(', ')
|
|
103
|
+
: '(o repositório não tem milestone nenhuma)');
|
|
104
|
+
|
|
105
|
+
if (!alvo) return { milestone: null, error: `Milestone não informada. Existem: ${lista()}.` };
|
|
106
|
+
|
|
107
|
+
const exata = milestones.find(m => m.title === alvo);
|
|
108
|
+
if (exata) return { milestone: exata, error: null };
|
|
109
|
+
|
|
110
|
+
const caseInsensitive = milestones.filter(m => m.title.toLowerCase() === alvo.toLowerCase());
|
|
111
|
+
if (caseInsensitive.length === 1) return { milestone: caseInsensitive[0], error: null };
|
|
112
|
+
if (caseInsensitive.length > 1) {
|
|
113
|
+
return { milestone: null, error: `Milestone "${alvo}" é ambígua entre ${lista()}.` };
|
|
114
|
+
}
|
|
115
|
+
return { milestone: null, error: `Milestone "${alvo}" não existe. Existem: ${lista()}.` };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Veredito final da rodada (função PURA).
|
|
120
|
+
*
|
|
121
|
+
* `bloqueios` impedem gerar; `avisos` não. A distinção é a razão de o comando
|
|
122
|
+
* existir: sair 1 por algo que não impede a geração faria o usuário aprender a
|
|
123
|
+
* ignorar o preflight, que é o mesmo que não o ter.
|
|
124
|
+
*
|
|
125
|
+
* @returns {{ status: 'ok'|'aviso'|'bloqueio', bloqueios: string[], avisos: string[] }}
|
|
126
|
+
*/
|
|
127
|
+
export function inspectPreflight({
|
|
128
|
+
tokenOk = true, modo = null, publicacao = null, inventario = null,
|
|
129
|
+
} = {}) {
|
|
130
|
+
const bloqueios = [];
|
|
131
|
+
const avisos = [];
|
|
132
|
+
|
|
133
|
+
if (!tokenOk) bloqueios.push('Sem token utilizável para este repositório.');
|
|
134
|
+
|
|
135
|
+
if (modo?.status === 'problem') bloqueios.push(modo.summary);
|
|
136
|
+
else if (modo?.status === 'warn') avisos.push(modo.summary);
|
|
137
|
+
|
|
138
|
+
// A publicação por PR é o desfecho de TODA geração: sem ela o documento é
|
|
139
|
+
// gerado, o commit criado e nenhum PR aparece — a falha mais cara do fluxo,
|
|
140
|
+
// porque o modelo já foi pago quando ela acontece.
|
|
141
|
+
if (publicacao?.status === 'fail') bloqueios.push(...publicacao.notes);
|
|
142
|
+
else if (publicacao?.status === 'warn') avisos.push(...publicacao.notes);
|
|
143
|
+
|
|
144
|
+
if (inventario) {
|
|
145
|
+
if (inventario.features.length === 0) {
|
|
146
|
+
bloqueios.push('Nenhuma Feature [FEATURE] nesta milestone.');
|
|
147
|
+
} else if (inventario.gerar.length === 0) {
|
|
148
|
+
avisos.push('Nenhuma Feature pendente: todas já têm spec.md.');
|
|
149
|
+
}
|
|
150
|
+
if (inventario.gatilhosPendentes.length > 0) {
|
|
151
|
+
avisos.push(
|
|
152
|
+
`${inventario.gatilhosPendentes.length} issue(s) com label de gatilho pendente — ` +
|
|
153
|
+
'o `run` se recusa a executar nelas até a label sair.'
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (inventario.aguardandoMerge.length > 0) {
|
|
157
|
+
avisos.push(
|
|
158
|
+
`${inventario.aguardandoMerge.length} spec(s) já geradas aguardando merge — ` +
|
|
159
|
+
'NÃO as gere de novo: o documento existe e regerá-lo descarta a revisão.'
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (inventario.indefinidas.length > 0) {
|
|
163
|
+
avisos.push(
|
|
164
|
+
`${inventario.indefinidas.length} Feature(s) com estado indeterminado (falha de rede) — ` +
|
|
165
|
+
'confirme antes de gerar, para não pagar duas vezes.'
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (bloqueios.length) return { status: 'bloqueio', bloqueios, avisos };
|
|
171
|
+
if (avisos.length) return { status: 'aviso', bloqueios, avisos };
|
|
172
|
+
return { status: 'ok', bloqueios, avisos };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const MARCA = { local: '✓ no disco', remote: '✓ na base', 'pending-pr': '⏳ em PR aberto', 'branch-only': '⚠ em branch sem PR', missing: '· gerar', unknown: '? indeterminado' };
|
|
176
|
+
|
|
177
|
+
export async function preflight({ milestone: milestoneArg, json = false } = {}) {
|
|
178
|
+
const saida = { milestone: null, modo: null, publicacao: null, inventario: null, veredito: null };
|
|
179
|
+
const falhar = (msg) => {
|
|
180
|
+
if (json) console.log(JSON.stringify({ ...saida, erro: msg }, null, 2));
|
|
181
|
+
else p.log.error(msg);
|
|
182
|
+
process.exitCode = 1;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
if (!json) p.intro(chalk.bold('spec-wave preflight'));
|
|
186
|
+
|
|
187
|
+
const { config, root, error: configError } = loadConfig();
|
|
188
|
+
if (configError || !config?.owner || !config?.repo) {
|
|
189
|
+
return falhar(
|
|
190
|
+
`${configError || `${CONFIG_FILE} sem owner/repo`} — rode \`spec-wave init\` antes.`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
const { owner, repo } = config;
|
|
194
|
+
|
|
195
|
+
let token;
|
|
196
|
+
try {
|
|
197
|
+
token = await resolveToken();
|
|
198
|
+
} catch (err) {
|
|
199
|
+
return falhar(`Sem token utilizável para ${owner}/${repo}: ${err.message}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const s = json ? null : p.spinner();
|
|
203
|
+
s?.start('Consultando o repositório...');
|
|
204
|
+
|
|
205
|
+
let base;
|
|
206
|
+
try {
|
|
207
|
+
base = await getRepoDefaultBranch(token, owner, repo);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
s?.stop('');
|
|
210
|
+
return falhar(`Não foi possível ler o repositório ${owner}/${repo}: ${err.message}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- Modo de execução: config × variável (o mesmo par que o doctor confere).
|
|
214
|
+
let variable;
|
|
215
|
+
try {
|
|
216
|
+
variable = await getRepoVariable(token, owner, repo, EXECUTION_VARIABLE);
|
|
217
|
+
} catch {
|
|
218
|
+
variable = undefined; // exige admin — "não deu para ler" não é "ausente"
|
|
219
|
+
}
|
|
220
|
+
const modo = describeModeState({
|
|
221
|
+
configured: configuredMode(config),
|
|
222
|
+
variable,
|
|
223
|
+
unguardedWorkflows: unguardedWorkflows(root),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// --- Publicação por Pull Request: é o desfecho de toda geração.
|
|
227
|
+
const publicacao = inspectPrPublishing(
|
|
228
|
+
await readPrPublishingContext({ token, owner, repo, root })
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// --- Inventário da milestone.
|
|
232
|
+
let milestones;
|
|
233
|
+
try {
|
|
234
|
+
milestones = await listMilestones(token, owner, repo);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
s?.stop('');
|
|
237
|
+
return falhar(`Não foi possível listar as milestones: ${err.message}`);
|
|
238
|
+
}
|
|
239
|
+
const { milestone, error: msError } = resolveMilestone(milestones, milestoneArg);
|
|
240
|
+
if (msError) {
|
|
241
|
+
s?.stop('');
|
|
242
|
+
return falhar(msError);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let issues;
|
|
246
|
+
try {
|
|
247
|
+
issues = await listIssuesByMilestone(token, owner, repo, milestone.number);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
s?.stop('');
|
|
250
|
+
return falhar(`Não foi possível listar as issues da milestone "${milestone.title}": ${err.message}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// O estado do spec.md de cada Feature, nas quatro camadas. Uma issue por vez:
|
|
254
|
+
// são até 3 requisições cada, e disparar tudo de uma vez numa milestone de 20
|
|
255
|
+
// Features é o caminho mais curto para o rate limit secundário.
|
|
256
|
+
const featureIssues = issues.filter(i => detectIssueType(i) === 'Feature');
|
|
257
|
+
const specStates = {};
|
|
258
|
+
for (const issue of featureIssues) {
|
|
259
|
+
const docs = featureDocPaths(root, issue, 'Feature');
|
|
260
|
+
const estados = await artifactStates({
|
|
261
|
+
token, owner, repo, root, base,
|
|
262
|
+
issueNumber: issue.number,
|
|
263
|
+
docs: [{ doc: 'spec', pathRel: docs.spec.rel }],
|
|
264
|
+
});
|
|
265
|
+
specStates[issue.number] = estados.spec;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const inventario = inspectMilestone({ issues, specStates });
|
|
269
|
+
const veredito = inspectPreflight({ tokenOk: true, modo, publicacao, inventario });
|
|
270
|
+
s?.stop(`Milestone "${milestone.title}": ${inventario.features.length} Feature(s).`);
|
|
271
|
+
|
|
272
|
+
Object.assign(saida, {
|
|
273
|
+
milestone: { title: milestone.title, number: milestone.number, base },
|
|
274
|
+
modo: { configured: configuredMode(config), variable: variable ?? null, status: modo.status, summary: modo.summary },
|
|
275
|
+
publicacao: { status: publicacao.status, notes: publicacao.notes },
|
|
276
|
+
inventario,
|
|
277
|
+
veredito,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
if (json) {
|
|
281
|
+
console.log(JSON.stringify(saida, null, 2));
|
|
282
|
+
if (veredito.status === 'bloqueio') process.exitCode = 1;
|
|
283
|
+
return saida;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------- Relatório ----------
|
|
287
|
+
const linhas = [];
|
|
288
|
+
linhas.push(`${chalk.bold('Repositório:')} ${owner}/${repo} (base: ${base})`);
|
|
289
|
+
linhas.push(`${chalk.bold('Modo:')} config=${configuredMode(config)} · variável=${variable ?? '<ausente>'}`);
|
|
290
|
+
linhas.push(` ${modo.summary}`);
|
|
291
|
+
linhas.push(`${chalk.bold('Publicação por PR:')} ${publicacao.status}`);
|
|
292
|
+
for (const nota of publicacao.notes) linhas.push(` ${nota}`);
|
|
293
|
+
p.note(linhas.join('\n'), 'Ambiente');
|
|
294
|
+
|
|
295
|
+
const inv = [];
|
|
296
|
+
for (const f of inventario.features) {
|
|
297
|
+
const marca = f.closed ? '× fechada' : (MARCA[f.spec] || f.spec);
|
|
298
|
+
const pr = f.pr ? chalk.dim(` PR #${f.pr.number}`) : '';
|
|
299
|
+
inv.push(` ${marca.padEnd(20)} #${f.number} ${f.title.slice(0, 60)}${pr}`);
|
|
300
|
+
}
|
|
301
|
+
if (inventario.gatilhosPendentes.length) {
|
|
302
|
+
inv.push('');
|
|
303
|
+
inv.push(chalk.yellow(' Labels de gatilho pendentes (remova antes de gerar):'));
|
|
304
|
+
for (const g of inventario.gatilhosPendentes) inv.push(` #${g.number}: ${g.labels.join(', ')}`);
|
|
305
|
+
}
|
|
306
|
+
p.note(inv.join('\n'), `Milestone "${milestone.title}" — ${inventario.gerar.length} a gerar`);
|
|
307
|
+
|
|
308
|
+
for (const a of veredito.avisos) p.log.warn(a);
|
|
309
|
+
for (const b of veredito.bloqueios) p.log.error(b);
|
|
310
|
+
|
|
311
|
+
if (veredito.status === 'bloqueio') {
|
|
312
|
+
p.outro(`${veredito.bloqueios.length} bloqueio(s). Resolva antes de gerar.`);
|
|
313
|
+
process.exitCode = 1;
|
|
314
|
+
return saida;
|
|
315
|
+
}
|
|
316
|
+
p.outro(
|
|
317
|
+
inventario.gerar.length
|
|
318
|
+
? `Preflight ok. Confirme a lista com o usuário antes de gerar ${inventario.gerar.length} spec(s).`
|
|
319
|
+
: 'Preflight ok. Nada a gerar.'
|
|
320
|
+
);
|
|
321
|
+
return saida;
|
|
322
|
+
}
|
package/src/commands/run.mjs
CHANGED
|
@@ -81,7 +81,7 @@ export function lockPath(root, key) {
|
|
|
81
81
|
return path.join(gitCommonDir(root), 'spec-wave', `run-${key}.lock`);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
function acquireLock(root, key) {
|
|
84
|
+
export function acquireLock(root, key) {
|
|
85
85
|
const file = lockPath(root, key);
|
|
86
86
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
87
87
|
const payload = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
|
|
@@ -108,7 +108,7 @@ function acquireLock(root, key) {
|
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
function releaseLock(file) {
|
|
111
|
+
export function releaseLock(file) {
|
|
112
112
|
try {
|
|
113
113
|
if (file) unlinkSync(file);
|
|
114
114
|
} catch { /* já removido */ }
|
|
@@ -348,7 +348,8 @@ async function runForPr({ prNumber, dryRun, yes, only, json }) {
|
|
|
348
348
|
process.exitCode = decision.blocked ? 2 : 0;
|
|
349
349
|
return decision;
|
|
350
350
|
}
|
|
351
|
-
|
|
351
|
+
// Mergeado, o pedido de mudanças pré-merge já foi decidido por quem mergeou.
|
|
352
|
+
if (verdict.changesRequestedAfterApproval && !pr.merged_at && !yes) {
|
|
352
353
|
console.log(chalk.yellow('\n⛔ needs-confirmation: há pedido de mudanças além da aprovação. Confirme com `--yes`.'));
|
|
353
354
|
process.exitCode = 2;
|
|
354
355
|
return decision;
|
package/src/commands/update.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from '../api/github-rest.mjs';
|
|
13
13
|
import {
|
|
14
14
|
resolveBranchName, composePrTitle, composePrBody, buildCommitMessage,
|
|
15
|
-
decideConfigInPr, explainGitWriteError,
|
|
15
|
+
decideConfigInPr, decideSkillInPr, composeConfigCleanupHint, explainGitWriteError,
|
|
16
16
|
} from '../lib/pr-branch.mjs';
|
|
17
17
|
// MESMO readTemplate do init: resolve {{CLI_VERSION}} antes da comparação byte a
|
|
18
18
|
// byte com o remoto. Se só o init resolvesse, todo update veria os workflows
|
|
@@ -20,10 +20,11 @@ import {
|
|
|
20
20
|
import { readTemplate } from '../lib/templates.mjs';
|
|
21
21
|
import {
|
|
22
22
|
TARGETS, SKILL_SOURCE, CLI_VERSION, parseSkill, renderContent,
|
|
23
|
-
mergeAgentsFile, resolveDest, isDetected, skillCopyReason, versionBanner,
|
|
23
|
+
mergeAgentsFile, mergeAgentsContent, resolveDest, isDetected, skillCopyReason, versionBanner,
|
|
24
24
|
} from './install-skill.mjs';
|
|
25
25
|
import { planPluginSkillFiles } from '../lib/plugin-skills.mjs';
|
|
26
26
|
import { findConfigPath } from '../lib/project-root.mjs';
|
|
27
|
+
import { currentGitBranch } from '../lib/repo-links.mjs';
|
|
27
28
|
|
|
28
29
|
// Arquivos do repo gerenciados pela CLI (comparados com o template empacotado).
|
|
29
30
|
const REPO_FILES = [
|
|
@@ -68,6 +69,41 @@ function applySkill(job) {
|
|
|
68
69
|
writeFileSync(job.dest.path, content, 'utf-8');
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Arquivos que uma atualização de skill grava, em caminhos RELATIVOS à raiz do
|
|
74
|
+
* repositório — a chave para perguntar à base se aquela cópia é versionada.
|
|
75
|
+
*
|
|
76
|
+
* Devolve [] quando o destino cai FORA da raiz, e é o que dispensa um caso
|
|
77
|
+
* especial para `--global`: `~/.claude/skills/...` e o `~/.gemini/AGENTS.md` do
|
|
78
|
+
* Antigravity jamais são conteúdo de repositório, e `path.relative` já diz isso.
|
|
79
|
+
*
|
|
80
|
+
* O formato `agents` (AGENTS.md) devolve `block` em vez de `content`: o conteúdo
|
|
81
|
+
* final depende do que existe na BASE — mesclar no arquivo local arrastaria para
|
|
82
|
+
* dentro do PR as edições não commitadas do desenvolvedor.
|
|
83
|
+
*
|
|
84
|
+
* @param {{dest: {path: string, format: string}, desired?: string}} job
|
|
85
|
+
* @param {string} repoRoot
|
|
86
|
+
* @returns {Array<{ relPath: string, content?: string, block?: string }>}
|
|
87
|
+
*/
|
|
88
|
+
export function skillRepoTargets(job, repoRoot) {
|
|
89
|
+
const items = job.dest.format === 'skills-dir'
|
|
90
|
+
? planPluginSkillFiles(job.dest.path, CLI_VERSION, versionBanner)
|
|
91
|
+
.map(f => ({ absPath: f.path, content: f.content }))
|
|
92
|
+
: [{
|
|
93
|
+
absPath: job.dest.path,
|
|
94
|
+
...(job.dest.format === 'agents' ? { block: job.desired } : { content: job.desired }),
|
|
95
|
+
}];
|
|
96
|
+
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const { absPath, ...rest } of items) {
|
|
99
|
+
const rel = path.relative(repoRoot, absPath);
|
|
100
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue;
|
|
101
|
+
// A API do GitHub fala POSIX; path.relative devolve no separador do SO.
|
|
102
|
+
out.push({ ...rest, relPath: rel.split(path.sep).join('/') });
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
71
107
|
/**
|
|
72
108
|
* Compara ALL_LABELS com as labels do repo (função PURA — testável).
|
|
73
109
|
*
|
|
@@ -325,6 +361,70 @@ export async function update(options = {}) {
|
|
|
325
361
|
}
|
|
326
362
|
}
|
|
327
363
|
|
|
364
|
+
// 2d) Skill: a BASE versiona alguma destas cópias?
|
|
365
|
+
//
|
|
366
|
+
// Até aqui o update gravava a skill em disco e o corpo do PR AFIRMAVA ao
|
|
367
|
+
// revisor que ela "não faz parte do repositório" — premissa fixa. Em repos que
|
|
368
|
+
// versionam `.claude/skills/spec-wave/SKILL.md` (ou `.agents/skills/`, ou o
|
|
369
|
+
// AGENTS.md) isso era falso duas vezes: sobrava um commit manual a cada bump, e
|
|
370
|
+
// até ele o repositório seguia distribuindo a skill da versão anterior para
|
|
371
|
+
// quem clonasse. A checagem é a MESMA do .spec-wave.json — perguntar à base —,
|
|
372
|
+
// e a política também: só entra no PR o que o projeto já versiona.
|
|
373
|
+
let skillPrFiles = []; // [{ relPath, content, reason, agent }]
|
|
374
|
+
// agente → motivo da PRIMEIRA cópia incluída. É o motivo que o resumo mostra:
|
|
375
|
+
// dizer "versionada no repo" para uma cópia que entrou por `--skill-in-pr`
|
|
376
|
+
// seria repetir, em miniatura, a premissa que este código veio corrigir.
|
|
377
|
+
const skillPrAgents = new Map();
|
|
378
|
+
// `skillRepoTargets` já devolve vazio para o que mora fora da raiz, o que cobre
|
|
379
|
+
// o `--global` inteiro sem um caso especial: nada a perguntar, nada a gastar.
|
|
380
|
+
const skillCandidates = prMode
|
|
381
|
+
? skillJobs
|
|
382
|
+
.map(job => ({ job, targets: skillRepoTargets(job, path.dirname(configPath)) }))
|
|
383
|
+
.filter(c => c.targets.length)
|
|
384
|
+
: [];
|
|
385
|
+
if (skillCandidates.length) {
|
|
386
|
+
const tk = await getToken();
|
|
387
|
+
const s = p.spinner();
|
|
388
|
+
s.start('Verificando se o repositório versiona a skill...');
|
|
389
|
+
try {
|
|
390
|
+
for (const { job, targets } of skillCandidates) {
|
|
391
|
+
// Em paralelo: o destino do Codex são 20+ arquivos, e um GET por vez
|
|
392
|
+
// transformaria a checagem na parte mais lenta do comando.
|
|
393
|
+
const remotes = await Promise.all(
|
|
394
|
+
targets.map(t => getFileContent(tk, owner, repo, t.relPath, base))
|
|
395
|
+
);
|
|
396
|
+
targets.forEach((t, i) => {
|
|
397
|
+
const remote = remotes[i];
|
|
398
|
+
const desired = t.block !== undefined
|
|
399
|
+
? mergeAgentsContent(remote ?? '', t.block)
|
|
400
|
+
: t.content;
|
|
401
|
+
const d = decideSkillInPr({ remote, desired, force: options.skillInPr });
|
|
402
|
+
if (!d.included) return;
|
|
403
|
+
skillPrFiles.push({
|
|
404
|
+
relPath: t.relPath,
|
|
405
|
+
content: desired,
|
|
406
|
+
reason: remote == null ? 'ausente' : 'desatualizado',
|
|
407
|
+
agent: job.target.name,
|
|
408
|
+
});
|
|
409
|
+
if (!skillPrAgents.has(job.target.name)) skillPrAgents.set(job.target.name, d.reason);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
s.stop(skillPrFiles.length
|
|
413
|
+
? `Skill versionada no repositório: ${skillPrFiles.length} arquivo(s) vão no Pull Request.`
|
|
414
|
+
: 'A skill não é versionada neste repositório — segue apenas local.');
|
|
415
|
+
} catch (err) {
|
|
416
|
+
// Falhar aqui não pode custar o update inteiro: sem a resposta da base, a
|
|
417
|
+
// skill volta a ser só local — o comportamento anterior, agora explícito.
|
|
418
|
+
s.stop('');
|
|
419
|
+
p.log.warn(`Não foi possível checar a skill na base: ${err.message} — ela seguirá apenas local.`);
|
|
420
|
+
skillPrFiles = [];
|
|
421
|
+
skillPrAgents.clear();
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
const skillLocalAgents = skillJobs
|
|
425
|
+
.map(j => j.target.name)
|
|
426
|
+
.filter(n => !skillPrAgents.has(n));
|
|
427
|
+
|
|
328
428
|
// Decisão preliminar sobre o config. O conteúdo regenerado ainda não existe;
|
|
329
429
|
// `willRegenerate` cobre isso, porque o regenerado SEMPRE difere do remoto
|
|
330
430
|
// (refreshedAt muda a cada execução). Na aplicação a decisão é recalculada com
|
|
@@ -355,21 +455,39 @@ export async function update(options = {}) {
|
|
|
355
455
|
const lines = [];
|
|
356
456
|
if (skillJobs.length) {
|
|
357
457
|
lines.push(chalk.bold('Skill:'));
|
|
358
|
-
for (const j of skillJobs)
|
|
458
|
+
for (const j of skillJobs) {
|
|
459
|
+
const destino = skillPrAgents.has(j.target.name)
|
|
460
|
+
? chalk.dim(` → vai no PR: ${skillPrAgents.get(j.target.name)}`)
|
|
461
|
+
: '';
|
|
462
|
+
lines.push(` ${chalk.yellow('↻')} ${j.target.name} (${j.reason})${destino}\n ${chalk.dim(j.dest.path)}`);
|
|
463
|
+
}
|
|
359
464
|
}
|
|
360
465
|
if (configStale) {
|
|
361
466
|
lines.push(chalk.bold('Config local:'));
|
|
362
467
|
lines.push(` ${chalk.yellow('↻')} ${CONFIG_FILE} — ${configStale.reason}` +
|
|
363
468
|
(configStale.canApply ? '' : chalk.dim(' (sem project.id — rode `init` sem --skip-project)')));
|
|
364
469
|
}
|
|
470
|
+
// Cabeçalho ÚNICO no modo PR: workflows, skill e config viajam no mesmo commit,
|
|
471
|
+
// e três títulos para um commit só sugeriam três destinos diferentes.
|
|
472
|
+
let prHeaderShown = false;
|
|
473
|
+
const prHeader = () => {
|
|
474
|
+
if (prHeaderShown) return;
|
|
475
|
+
prHeaderShown = true;
|
|
476
|
+
lines.push(chalk.bold(`Pull Request (${branch} → ${base}):`));
|
|
477
|
+
};
|
|
365
478
|
if (repoFiles.length) {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
: chalk.bold('Arquivos do repo:'));
|
|
479
|
+
if (prMode) prHeader();
|
|
480
|
+
else lines.push(chalk.bold('Arquivos do repo:'));
|
|
369
481
|
for (const f of repoFiles) lines.push(` ${chalk.yellow('↻')} ${f.repoPath} (${f.reason})`);
|
|
370
482
|
}
|
|
483
|
+
if (skillPrFiles.length) {
|
|
484
|
+
prHeader();
|
|
485
|
+
for (const f of skillPrFiles) {
|
|
486
|
+
lines.push(` ${chalk.yellow('↻')} ${f.relPath} (${f.reason} — skill ${f.agent})`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
371
489
|
if (prMode && configDecision.included) {
|
|
372
|
-
|
|
490
|
+
prHeader();
|
|
373
491
|
lines.push(` ${chalk.yellow('↻')} ${CONFIG_FILE} (${configDecision.reason})`);
|
|
374
492
|
}
|
|
375
493
|
if (prMode && doConfig && !configDecision.included && configDecision.reason) {
|
|
@@ -388,7 +506,8 @@ export async function update(options = {}) {
|
|
|
388
506
|
p.note(lines.join('\n'), `${total} item(ns) desatualizado(s)`);
|
|
389
507
|
|
|
390
508
|
// --branch sem efeito: melhor dizer POR QUE do que criar uma branch inútil.
|
|
391
|
-
const prHasPayload = prMode
|
|
509
|
+
const prHasPayload = prMode
|
|
510
|
+
&& (repoFiles.length > 0 || skillPrFiles.length > 0 || configDecision.included);
|
|
392
511
|
if (prRequested && options.skipRepo) {
|
|
393
512
|
p.log.warn(
|
|
394
513
|
'--branch ignorado junto com --skip-repo: o Pull Request existe justamente para ' +
|
|
@@ -408,7 +527,7 @@ export async function update(options = {}) {
|
|
|
408
527
|
|
|
409
528
|
if (options.dryRun) {
|
|
410
529
|
if (prHasPayload) {
|
|
411
|
-
const n = repoFiles.length + (configDecision.included ? 1 : 0);
|
|
530
|
+
const n = repoFiles.length + skillPrFiles.length + (configDecision.included ? 1 : 0);
|
|
412
531
|
p.note(
|
|
413
532
|
`Branch: ${branch}${branchExists ? ' (já existe — o commit seria empilhado nela)' : ' (seria criada)'}\n` +
|
|
414
533
|
`Base: ${base}\n` +
|
|
@@ -516,6 +635,11 @@ export async function update(options = {}) {
|
|
|
516
635
|
const tk = await getToken();
|
|
517
636
|
const treeFiles = [
|
|
518
637
|
...repoFiles.map(f => ({ path: f.repoPath, reason: f.reason, content: f.local })),
|
|
638
|
+
...skillPrFiles.map(f => ({
|
|
639
|
+
path: f.relPath,
|
|
640
|
+
reason: `${f.reason} — skill ${f.agent}`,
|
|
641
|
+
content: f.content,
|
|
642
|
+
})),
|
|
519
643
|
...(finalConfigDecision.included
|
|
520
644
|
? [{
|
|
521
645
|
path: CONFIG_FILE,
|
|
@@ -562,7 +686,7 @@ export async function update(options = {}) {
|
|
|
562
686
|
files: treeFiles,
|
|
563
687
|
config: doConfig ? finalConfigDecision : null,
|
|
564
688
|
labels: labelResult,
|
|
565
|
-
skill:
|
|
689
|
+
skill: { inPr: [...skillPrAgents.keys()], local: skillLocalAgents },
|
|
566
690
|
}),
|
|
567
691
|
});
|
|
568
692
|
if (!pr) {
|
|
@@ -605,9 +729,16 @@ export async function update(options = {}) {
|
|
|
605
729
|
(prUrl ? ` Revise e faça o merge do Pull Request: ${prUrl}` : '') +
|
|
606
730
|
(!prMode && repoFiles.length ? ' Arquivos do repo foram commitados no remoto.' : '') +
|
|
607
731
|
(configPending ? ` COMMITE o ${CONFIG_FILE} — quem clona o repo (dev-agent, Actions) lê a versão commitada.` : '') +
|
|
732
|
+
// A dica de descarte depende da branch do CHECKOUT, não de premissa:
|
|
733
|
+
// `git checkout -- <arquivo>` restaura o que está no índice, e rodado de uma
|
|
734
|
+
// branch de trabalho reverte o arquivo para a versão ANTIGA dela — o oposto
|
|
735
|
+
// do que a mensagem promete.
|
|
608
736
|
(finalConfigDecision.included
|
|
609
|
-
? `
|
|
610
|
-
|
|
737
|
+
? ` ${composeConfigCleanupHint({
|
|
738
|
+
file: CONFIG_FILE,
|
|
739
|
+
base,
|
|
740
|
+
current: currentGitBranch(path.dirname(configPath)),
|
|
741
|
+
})}`
|
|
611
742
|
: '')
|
|
612
743
|
);
|
|
613
744
|
}
|
package/src/lib/board.mjs
CHANGED
|
@@ -218,11 +218,27 @@ export async function advanceToStage(
|
|
|
218
218
|
const current = await getItemSingleSelectValue(token, itemId, etapaField.id).catch(() => null);
|
|
219
219
|
if (!shouldAdvanceStage(current, targetStage)) return false;
|
|
220
220
|
const optionId = etapaField.options?.[targetStage];
|
|
221
|
-
|
|
221
|
+
// Opção que não resolve é board divergente (coluna renomeada, init de outra
|
|
222
|
+
// versão) — pular a escrita e devolver true fazia o chamador imprimir ✅
|
|
223
|
+
// sem nada ter sido escrito. Melhor falhar nomeando o que faltou.
|
|
224
|
+
if (!optionId) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
`A Etapa "${targetStage}" não existe no board (opções: ` +
|
|
227
|
+
`${Object.keys(etapaField.options || {}).join(', ') || 'nenhuma'}). ` +
|
|
228
|
+
'O board divergiu da config — rode `spec-wave refresh` ou renomeie a coluna de volta.'
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
|
|
222
232
|
}
|
|
223
233
|
if (statusField?.id && targetStatus) {
|
|
224
234
|
const optionId = statusField.options?.[targetStatus];
|
|
225
|
-
if (optionId)
|
|
235
|
+
if (!optionId) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`O Status "${targetStatus}" não existe no board (opções: ` +
|
|
238
|
+
`${Object.keys(statusField.options || {}).join(', ') || 'nenhuma'}).`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
|
|
226
242
|
}
|
|
227
243
|
return true;
|
|
228
244
|
}
|