@spec-wave/cli 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -85,6 +85,14 @@ Ferramenta Node.js que configura e opera o fluxo via linha de comando.
85
85
  | `code-review.yml` | PR aberto/reaberto | Move Feature para `👀 Code Review` |
86
86
  | `qa.yml` | PR aprovado | Move Feature para `🧪 QA` |
87
87
 
88
+ **Re-executar uma etapa (`spec-wave:force`).** Adicionada *junto* de uma label de gatilho, a label `spec-wave:force` manda o comando re-executar a etapa ignorando os guards; ela é consumida pelo run (vale uma vez). Sozinha não dispara nada.
89
+
90
+ ```bash
91
+ gh issue edit 12 --add-label "spec-wave:force" --add-label "spec-wave:decompose"
92
+ ```
93
+
94
+ No `decompose` ela **fecha as sub-issues da decomposição anterior** (Stories e suas Tasks) antes de gerar as novas — operação destrutiva, com aviso no comentário da issue quando alguma já saiu de `✅ Ready`. Em `generate-spec`/`generate-plan` não muda nada: essas etapas já sobrescrevem o documento a cada acionamento. A flag equivalente para execução local é `--force`.
95
+
88
96
  ### Skill (`src/templates/skill/SKILL.md`)
89
97
 
90
98
  Skill que guia o usuário pelo fluxo via comandos como `/spec-wave spec 42`, `/spec-wave plan 42`, `/spec-wave decompose 42`. A skill lê o `.spec-wave.json` local, detecta o estado atual e executa os comandos corretos sem abrir wizards interativos. Instale-a no seu agente com `install-skill` (ver abaixo).
package/bin/spec-wave.mjs CHANGED
@@ -132,6 +132,7 @@ program
132
132
  .command('generate-plan')
133
133
  .description('Gera plan.md para uma Feature (usado pelo GitHub Action)')
134
134
  .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
135
+ .option('--force', 'Re-executa a etapa ignorando os guards (equivale à label spec-wave:force)')
135
136
  .action(async (options) => {
136
137
  const { generatePlan } = await import('../src/commands/generate-plan.mjs');
137
138
  await generatePlan(options).catch(err => { console.error(err.message); process.exit(1); });
@@ -141,6 +142,7 @@ program
141
142
  .command('generate-spec')
142
143
  .description('Gera spec.md para uma Feature (usado pelo GitHub Action)')
143
144
  .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
145
+ .option('--force', 'Re-executa a etapa ignorando os guards (equivale à label spec-wave:force)')
144
146
  .action(async (options) => {
145
147
  const { generateSpec } = await import('../src/commands/generate-spec.mjs');
146
148
  await generateSpec(options).catch(err => { console.error(err.message); process.exit(1); });
@@ -159,6 +161,7 @@ program
159
161
  .command('decompose')
160
162
  .description('Decompõe uma Feature em Stories e Tasks (usado pelo GitHub Action)')
161
163
  .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
164
+ .option('--force', 'Re-executa a etapa ignorando os guards (equivale à label spec-wave:force)')
162
165
  .action(async (options) => {
163
166
  const { decompose } = await import('../src/commands/decompose.mjs');
164
167
  await decompose(options).catch(err => { console.error(err.message); process.exit(1); });
@@ -222,6 +225,23 @@ program
222
225
  await story({ action, issue: n }).catch(err => { console.error(err.message); process.exit(1); });
223
226
  });
224
227
 
228
+ program
229
+ .command('dev-agent')
230
+ .description('Instala (--install/--build) ou executa (--run) o spec-wave-agent nesta máquina')
231
+ .option('--install', 'Baixa o binário da release, gera a config e (com --service) o serviço')
232
+ .option('--build', 'Clona o repo do agente e compila com cargo (alternativa ao --install)')
233
+ .option('--run', 'Executa o agente em foreground (Ctrl+C encerra com checkpoint)')
234
+ .option('--service', 'No --install/--build: também instala e habilita systemd/launchd')
235
+ .option('--tag <tag>', 'Release (--install) ou branch/tag (--build); padrão: última release / main')
236
+ .option('--debug', 'No --run: RUST_LOG=debug')
237
+ .option('--dry-run', 'Mostra o que seria instalado sem gravar')
238
+ .option('--force', 'Reinstala o binário e regrava a config')
239
+ .option('--yes', 'Modo não-interativo')
240
+ .action(async (options) => {
241
+ const { devAgent } = await import('../src/commands/dev-agent.mjs');
242
+ await devAgent(options).catch(err => { console.error(err.message); process.exit(1); });
243
+ });
244
+
225
245
  program
226
246
  .command('doctor')
227
247
  .description('Diagnostica a configuração do spec-wave no repositório atual')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Setup spec-driven GitHub workflow with Projects v2, labels, issue templates, and AI-powered Actions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,4 +28,4 @@
28
28
  "commander": "^13.1.0",
29
29
  "js-yaml": "^4.1.0"
30
30
  }
31
- }
31
+ }
@@ -198,6 +198,7 @@ export async function listSubIssues(token, issueNodeId) {
198
198
  number
199
199
  title
200
200
  body
201
+ state
201
202
  labels(first: 20) { nodes { name } }
202
203
  }
203
204
  }
@@ -211,6 +212,9 @@ export async function listSubIssues(token, issueNodeId) {
211
212
  title: n.title,
212
213
  body: n.body || '',
213
214
  nodeId: n.id,
215
+ // 'OPEN' | 'CLOSED' (GraphQL) normalizado para o vocabulário do REST, que
216
+ // é o que o resto do código compara (issue.state === 'closed').
217
+ state: n.state === 'CLOSED' ? 'closed' : 'open',
214
218
  labels: (n.labels?.nodes || []).map(l => l.name),
215
219
  }));
216
220
  }
@@ -101,6 +101,19 @@ export async function getIssue(token, owner, repo, issueNumber) {
101
101
  return res.data;
102
102
  }
103
103
 
104
+ // Fecha uma issue. Usado pelo re-decompose forçado para limpar as sub-issues
105
+ // da decomposição anterior antes de gerar as novas.
106
+ export async function closeIssue(token, owner, repo, issueNumber, reason = 'not_planned') {
107
+ const octokit = makeOctokit(token);
108
+ await octokit.rest.issues.update({
109
+ owner,
110
+ repo,
111
+ issue_number: issueNumber,
112
+ state: 'closed',
113
+ state_reason: reason,
114
+ });
115
+ }
116
+
104
117
  export async function deleteLabel(token, owner, repo, name) {
105
118
  const octokit = makeOctokit(token);
106
119
  try {
@@ -1,8 +1,9 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { resolveToken } from '../api/auth.mjs';
3
- import { getIssue, createIssue, removeLabel, addLabel, commentOnIssue, addBlockedBy } from '../api/github-rest.mjs';
4
- import { addSubIssue, listSubIssues } from '../api/github-graphql.mjs';
3
+ import { getIssue, createIssue, removeLabel, addLabel, commentOnIssue, addBlockedBy, closeIssue } from '../api/github-rest.mjs';
4
+ import { addSubIssue, listSubIssues, addProjectItem, getItemSingleSelectValue } from '../api/github-graphql.mjs';
5
5
  import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
6
+ import { isForced, consumeForceLabel } from '../lib/force.mjs';
6
7
  import { generateDocument } from '../lib/claude.mjs';
7
8
  import { runCritique } from '../lib/critique.mjs';
8
9
  import { recordUsage } from '../lib/usage-report.mjs';
@@ -10,7 +11,10 @@ import { formatDependencyLine } from '../lib/dependencies.mjs';
10
11
  import { lintLanguage } from '../lib/output-lint.mjs';
11
12
  import { slugify } from '../lib/slugify.mjs';
12
13
  import { detectIssueType } from '../lib/issue-type.mjs';
13
- import { DECOMPOSE_TARGETS, LABEL_DECOMPOSED, LABEL_CRITIQUE_FAILED, TARGET_LANGUAGE, STAGE_READY, PROGRESS_TODO } from '../config.mjs';
14
+ import {
15
+ DECOMPOSE_TARGETS, LABEL_DECOMPOSED, LABEL_CRITIQUE_FAILED, LABEL_FORCE,
16
+ TARGET_LANGUAGE, STAGE_READY, STAGE_DEVELOPMENT, STAGE_ORDER, PROGRESS_TODO,
17
+ } from '../config.mjs';
14
18
 
15
19
  // Adiciona a issue ao board na Etapa ✅ Ready / Status Todo. Best-effort; a
16
20
  // Etapa nunca retrocede (advanceToStage não toca itens já adiante).
@@ -19,15 +23,61 @@ async function moveToReady(token, project, etapaField, statusField, nodeId) {
19
23
  await advanceToStage(token, project, etapaField, statusField, nodeId, STAGE_READY, PROGRESS_TODO);
20
24
  }
21
25
 
22
- // Extrai JSON da resposta do modelo (tolera texto em volta).
23
- function parseJson(raw) {
24
- try {
25
- return JSON.parse(raw);
26
- } catch {
27
- const jsonMatch = raw.match(/\{[\s\S]*\}/);
28
- if (!jsonMatch) throw new Error('Claude did not return valid JSON');
29
- return JSON.parse(jsonMatch[0]);
26
+ // Dobra as barras invertidas que NÃO iniciam um escape válido de JSON. O corpo
27
+ // das tasks costuma trazer trecho de shell/YAML/regex ("\d+", "gradlew \" no fim
28
+ // da linha) e o modelo emite a barra crua: `JSON.parse` morre com "Bad escaped
29
+ // character". Escapes válidos são consumidos inteiros pela regex, então `\\`,
30
+ // `\n` e `\uXXXX` passam intactos.
31
+ function repairEscapes(text) {
32
+ return text.replace(/\\(u[0-9a-fA-F]{4}|["\\/bfnrt])?/g, (match, valid) => (valid ? match : '\\\\'));
33
+ }
34
+
35
+ // Trecho ao redor da posição que o V8 reporta ("... at position 18321"), para o
36
+ // log da Action mostrar O QUE quebrou em vez de só um offset.
37
+ function excerptAt(text, message) {
38
+ const at = /position (\d+)/.exec(message || '');
39
+ if (!at) return '';
40
+ const pos = Number(at[1]);
41
+ const excerpt = text.slice(Math.max(0, pos - 60), pos + 60).replace(/\s+/g, ' ').trim();
42
+ return excerpt ? ` Trecho: …${excerpt}…` : '';
43
+ }
44
+
45
+ /**
46
+ * Extrai o JSON da resposta do modelo (função PURA — testável).
47
+ *
48
+ * Tenta, em ordem: conteúdo de fence de código, resposta inteira e o primeiro
49
+ * objeto `{...}` do texto; cada candidato é parseado cru e, se falhar, com os
50
+ * escapes reparados. Sem candidato válido, lança com o motivo e o trecho.
51
+ *
52
+ * @param {string} raw resposta bruta do modelo
53
+ * @returns {object} objeto decodificado
54
+ */
55
+ export function parseModelJson(raw) {
56
+ const text = String(raw ?? '');
57
+ const candidates = [];
58
+ const fence = text.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
59
+ if (fence) candidates.push(fence[1]);
60
+ candidates.push(text);
61
+ const obj = text.match(/\{[\s\S]*\}/);
62
+ if (obj) candidates.push(obj[0]);
63
+
64
+ let failure = null;
65
+ for (const candidate of candidates) {
66
+ const trimmed = candidate.trim();
67
+ if (!trimmed) continue;
68
+ for (const attempt of [trimmed, repairEscapes(trimmed)]) {
69
+ try {
70
+ return JSON.parse(attempt);
71
+ } catch (err) {
72
+ failure = { message: err.message, text: attempt };
73
+ }
74
+ }
30
75
  }
76
+
77
+ throw new Error(
78
+ `O modelo não devolveu JSON válido: ${failure?.message ?? 'nenhum objeto encontrado na resposta'}.` +
79
+ (failure ? excerptAt(failure.text, failure.message) : '')
80
+ );
31
81
  }
32
82
 
33
83
  // Prefixo de título das sub-issues geradas por cada tipo decompoível.
@@ -40,13 +90,19 @@ const CHILD_PREFIX = { Feature: '[STORY]', RFC: '[TASK]' };
40
90
  * sub-issues já contêm um item do tipo-alvo (Feature → algum `[STORY]` no
41
91
  * título; RFC → algum `[TASK]`). Sub-issues de outro tipo não contam.
42
92
  *
93
+ * Com `force` (flag `--force` ou label `spec-wave:force`) nada é pulado — o
94
+ * chamador é quem limpa a decomposição anterior antes de gerar a nova.
95
+ *
43
96
  * @param {object} params
44
97
  * @param {Array<string|{name: string}>} [params.labels] labels da issue
45
98
  * @param {Array<{ number?: number, title?: string }>} [params.subIssues] sub-issues existentes
46
99
  * @param {string} params.type tipo da issue ('Feature' | 'RFC')
100
+ * @param {boolean} [params.force] re-executa ignorando os guards
47
101
  * @returns {{ skip: boolean, reason: string }}
48
102
  */
49
- export function shouldSkipDecompose({ labels = [], subIssues = [], type } = {}) {
103
+ export function shouldSkipDecompose({ labels = [], subIssues = [], type, force = false } = {}) {
104
+ if (force) return { skip: false, reason: '' };
105
+
50
106
  const names = labels
51
107
  .map(l => (typeof l === 'string' ? l : l?.name))
52
108
  .filter(Boolean);
@@ -66,6 +122,103 @@ export function shouldSkipDecompose({ labels = [], subIssues = [], type } = {})
66
122
  return { skip: false, reason: '' };
67
123
  }
68
124
 
125
+ /**
126
+ * Planeja a limpeza de um re-decompose forçado (função PURA — testável).
127
+ *
128
+ * Só entram as sub-issues do tipo-alvo (Feature → `[STORY]`, RFC → `[TASK]`)
129
+ * que ainda estão abertas — as já fechadas não precisam de nada. Separa as que
130
+ * já saíram de ✅ Ready (Etapa ≥ 🚧 Desenvolvimento): fechá-las descarta
131
+ * trabalho em andamento, então elas são destacadas no aviso.
132
+ *
133
+ * @param {object} params
134
+ * @param {Array<{number:number, title?:string, nodeId?:string, state?:string}>} [params.subIssues]
135
+ * @param {string} params.type tipo da issue pai ('Feature' | 'RFC')
136
+ * @param {Record<number, string|null>} [params.stages] Etapa atual por número de issue
137
+ * @returns {{ close: object[], started: object[] }} close: a fechar (com `stage`);
138
+ * started: subconjunto de close que já passou de Ready.
139
+ */
140
+ export function planForcedCleanup({ subIssues = [], type, stages = {} } = {}) {
141
+ const prefix = CHILD_PREFIX[type];
142
+ if (!prefix) return { close: [], started: [] };
143
+
144
+ const devIdx = STAGE_ORDER.indexOf(STAGE_DEVELOPMENT);
145
+ const close = subIssues
146
+ .filter(s => (s.title || '').includes(prefix) && s.state !== 'closed')
147
+ .map(s => ({ ...s, stage: stages[s.number] ?? null }));
148
+ const started = close.filter(s => {
149
+ const idx = s.stage ? STAGE_ORDER.indexOf(s.stage) : -1;
150
+ return idx !== -1 && devIdx !== -1 && idx >= devIdx;
151
+ });
152
+ return { close, started };
153
+ }
154
+
155
+ // Fecha a decomposição anterior (sub-issues do tipo-alvo e os filhos delas) e
156
+ // comenta o que foi fechado. Best-effort item a item: uma falha vira warn e o
157
+ // re-decompose segue — melhor uma issue órfã do que abortar no meio.
158
+ async function closeStaleSubIssues(ctx, subIssues) {
159
+ const { token, projectToken, owner, repo, issueNumber, project, etapaField, type } = ctx;
160
+
161
+ // Etapa de cada sub-issue (best-effort) — só para avisar o que já saiu de Ready.
162
+ const stages = {};
163
+ if (project?.id && etapaField?.id) {
164
+ await Promise.all(subIssues.map(async (s) => {
165
+ if (!s.nodeId) return;
166
+ try {
167
+ const itemId = await addProjectItem(projectToken, project.id, s.nodeId);
168
+ stages[s.number] = await getItemSingleSelectValue(projectToken, itemId, etapaField.id);
169
+ } catch {
170
+ stages[s.number] = null;
171
+ }
172
+ }));
173
+ }
174
+
175
+ const { close, started } = planForcedCleanup({ subIssues, type, stages });
176
+ if (close.length === 0) {
177
+ console.log('Re-decompose forçado: nenhuma sub-issue anterior aberta para fechar.');
178
+ return;
179
+ }
180
+
181
+ console.log(`Re-decompose forçado: fechando ${close.length} sub-issue(s) anterior(es)...`);
182
+ const closed = [];
183
+ for (const s of close) {
184
+ // Filhos primeiro (Tasks de uma Story): fechar só a Story deixaria as Tasks
185
+ // órfãs e abertas no board.
186
+ const children = s.nodeId ? await listSubIssues(token, s.nodeId).catch(() => []) : [];
187
+ for (const child of children.filter(c => c.state !== 'closed')) {
188
+ try {
189
+ await closeIssue(token, owner, repo, child.number);
190
+ } catch (err) {
191
+ console.warn(` Falha ao fechar #${child.number}: ${err.message}`);
192
+ }
193
+ }
194
+ try {
195
+ await closeIssue(token, owner, repo, s.number);
196
+ closed.push({ ...s, children: children.length });
197
+ console.log(` #${s.number} fechada${children.length ? ` (+${children.length} filha(s))` : ''}.`);
198
+ } catch (err) {
199
+ console.warn(` Falha ao fechar #${s.number}: ${err.message}`);
200
+ }
201
+ }
202
+
203
+ if (closed.length === 0) return;
204
+ const lines = closed.map(s =>
205
+ `- #${s.number} ${s.title}${s.stage ? ` — Etapa ${s.stage}` : ''}` +
206
+ (s.children ? ` _(+${s.children} sub-issue(s))_` : '')
207
+ );
208
+ const startedWarning = started.length > 0
209
+ ? `\n\n⚠️ ${started.length} delas já tinha(m) saído de **${STAGE_READY}**: ` +
210
+ `${started.map(s => `#${s.number} (${s.stage})`).join(', ')} — ` +
211
+ 'confira se algum trabalho em andamento foi descartado.'
212
+ : '';
213
+ await commentOnIssue(
214
+ token, owner, repo, parseInt(issueNumber, 10),
215
+ `🔀 **Re-decompose forçado** (label \`${LABEL_FORCE}\`)\n\n` +
216
+ `Fechadas ${closed.length} sub-issue(s) da decomposição anterior:\n\n${lines.join('\n')}` +
217
+ startedWarning +
218
+ '\n\nGerando a nova decomposição…'
219
+ ).catch(() => {});
220
+ }
221
+
69
222
  // Lint de idioma sobre títulos+corpos gerados; retorna aviso pronto para
70
223
  // anexar ao comentário final ('' se limpo).
71
224
  function formatItemsLintWarning(texts) {
@@ -147,7 +300,7 @@ async function decomposeFeature(ctx) {
147
300
  `\n## plan.md\n${planContent}`,
148
301
  ].join('\n');
149
302
 
150
- const decomposition = parseJson(await generateDocument(FEATURE_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
303
+ const decomposition = parseModelJson(await generateDocument(FEATURE_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
151
304
 
152
305
  // Crítica adversarial ANTES de criar qualquer issue: stories que contradizem
153
306
  // a spec/plan não devem virar trabalho. Crítica indisponível → só avisa.
@@ -283,7 +436,7 @@ async function decomposeRFC(ctx) {
283
436
  `\n## Descrição\n${issue.body || '(sem descrição)'}`,
284
437
  ].join('\n');
285
438
 
286
- const decomposition = parseJson(await generateDocument(RFC_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
439
+ const decomposition = parseModelJson(await generateDocument(RFC_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
287
440
  const rfcNodeId = issue.node_id;
288
441
  const tasks = decomposition.tasks || [];
289
442
  const created = [];
@@ -327,7 +480,7 @@ async function decomposeRFC(ctx) {
327
480
  console.log(`Decomposição concluída: ${created.length} tasks criadas.`);
328
481
  }
329
482
 
330
- export async function decompose({ issueNumber }) {
483
+ export async function decompose({ issueNumber, force = false }) {
331
484
  const token = await resolveToken();
332
485
  // PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
333
486
  // Fallback para GITHUB_TOKEN (só funciona em repos pessoais sem org restrictions).
@@ -366,17 +519,25 @@ export async function decompose({ issueNumber }) {
366
519
  console.warn(`Não foi possível listar sub-issues: ${err.message} — seguindo sem o guard de sub-issues.`);
367
520
  subIssues = [];
368
521
  }
369
- const guard = shouldSkipDecompose({ labels: issue.labels || [], subIssues, type });
522
+ // Force (flag --force ou label spec-wave:force): ignora o guard e limpa a
523
+ // decomposição anterior antes de gerar a nova.
524
+ const forced = isForced({ labels: issue.labels || [], flag: force });
525
+ const guard = shouldSkipDecompose({ labels: issue.labels || [], subIssues, type, force: forced });
370
526
  if (guard.skip) {
371
527
  console.log(`Decompose ignorado: ${guard.reason}.`);
372
528
  await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
373
529
  await commentOnIssue(
374
530
  token, owner, repo, parseInt(issueNumber, 10),
375
- `⏭️ **decompose ignorado:** ${guard.reason}. Para forçar, remova a label ` +
376
- `\`${LABEL_DECOMPOSED}\` (e apague as sub-issues antigas se quiser re-gerar).`
531
+ `⏭️ **decompose ignorado:** ${guard.reason}.\n\n` +
532
+ `Para re-decompor, adicione a label \`${LABEL_FORCE}\` junto com \`spec-wave:decompose\` ` +
533
+ 'as sub-issues da decomposição anterior serão **fechadas** e novas serão geradas.'
377
534
  ).catch(() => {});
378
535
  return;
379
536
  }
537
+ // Consome a label assim que ela é lida — se o run for cancelado/morto mais
538
+ // adiante, ela não fica pendurada forçando silenciosamente os runs seguintes.
539
+ await consumeForceLabel(token, owner, repo, parseInt(issueNumber, 10));
540
+ if (forced) console.log(`Modo forçado ativo (${force ? 'flag --force' : `label ${LABEL_FORCE}`}).`);
380
541
 
381
542
  // Projeto + campos Etapa/Status do board (reutilizados em todos os itens).
382
543
  const { project, error: projectError } = loadProjectConfig();
@@ -399,8 +560,11 @@ export async function decompose({ issueNumber }) {
399
560
  // Coletor de uso de IA — o finally registra o custo já incorrido mesmo nos
400
561
  // fluxos que retornam cedo (ex.: abort da crítica grave) ou que falham.
401
562
  const usageEntries = [];
402
- const ctx = { token, projectToken, owner, repo, issue, issueNumber, project, etapaField, statusField, usage: usageEntries };
563
+ const ctx = { token, projectToken, owner, repo, issue, issueNumber, type, project, etapaField, statusField, usage: usageEntries };
403
564
  try {
565
+ // Limpeza do re-decompose forçado ANTES de gerar: fecha as sub-issues da
566
+ // decomposição anterior para o board não ficar com dois conjuntos.
567
+ if (forced) await closeStaleSubIssues(ctx, subIssues);
404
568
  if (type === 'Feature') await decomposeFeature(ctx);
405
569
  else if (type === 'RFC') await decomposeRFC(ctx);
406
570
  } finally {