@spec-wave/cli 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +1 -0
  2. package/bin/spec-wave.mjs +41 -2
  3. package/package.json +8 -2
  4. package/src/agent/anthropic-agent.mjs +337 -0
  5. package/src/agent/errors.mjs +33 -0
  6. package/src/agent/index.mjs +108 -0
  7. package/src/agent/openrouter-agent.mjs +378 -0
  8. package/src/agent/run-types.mjs +59 -0
  9. package/src/agent/telemetry.mjs +54 -0
  10. package/src/agent/tools.mjs +452 -0
  11. package/src/agent/tracing.mjs +106 -0
  12. package/src/api/github-rest.mjs +8 -0
  13. package/src/commands/bug.mjs +8 -0
  14. package/src/commands/code-review.mjs +45 -4
  15. package/src/commands/decompose.mjs +11 -49
  16. package/src/commands/dev-agent.mjs +3 -3
  17. package/src/commands/doctor.mjs +77 -6
  18. package/src/commands/generate-bug.mjs +195 -0
  19. package/src/commands/generate-plan.mjs +6 -20
  20. package/src/commands/generate-spec.mjs +6 -22
  21. package/src/commands/implement.mjs +105 -2
  22. package/src/commands/init.mjs +3 -3
  23. package/src/commands/install-skill.mjs +72 -16
  24. package/src/commands/issue.mjs +9 -7
  25. package/src/commands/move.mjs +11 -1
  26. package/src/commands/qa.mjs +23 -2
  27. package/src/commands/refresh.mjs +145 -5
  28. package/src/commands/triage.mjs +174 -0
  29. package/src/commands/update.mjs +16 -3
  30. package/src/commands/validate.mjs +82 -10
  31. package/src/config.mjs +159 -1
  32. package/src/lib/bug-context.mjs +160 -0
  33. package/src/lib/bug-doc.mjs +51 -0
  34. package/src/lib/bug-triage.mjs +81 -0
  35. package/src/lib/claude.mjs +71 -254
  36. package/src/lib/critique.mjs +43 -30
  37. package/src/lib/implement-board.mjs +12 -1
  38. package/src/lib/plugin-skills.mjs +122 -0
  39. package/src/lib/prompt-loader.mjs +257 -0
  40. package/src/lib/skill-file.mjs +35 -0
  41. package/src/plugin/.claude-plugin/plugin.json +20 -0
  42. package/src/plugin/README.md +73 -0
  43. package/src/plugin/skills/bug/SKILL.md +60 -0
  44. package/src/plugin/skills/bug/model-prompt.critique.md +48 -0
  45. package/src/plugin/skills/bug/model-prompt.md +74 -0
  46. package/src/plugin/skills/decompose/SKILL.md +111 -0
  47. package/src/plugin/skills/decompose/model-prompt.critique.md +46 -0
  48. package/src/plugin/skills/decompose/model-prompt.feature.md +69 -0
  49. package/src/plugin/skills/decompose/model-prompt.rfc.md +52 -0
  50. package/src/plugin/skills/doctor/SKILL.md +51 -0
  51. package/src/plugin/skills/fix-pr/SKILL.md +130 -0
  52. package/src/plugin/skills/implement/SKILL.md +102 -0
  53. package/src/plugin/skills/info/SKILL.md +40 -0
  54. package/src/plugin/skills/issue/SKILL.md +63 -0
  55. package/src/plugin/skills/move/SKILL.md +52 -0
  56. package/src/plugin/skills/order/SKILL.md +36 -0
  57. package/src/plugin/skills/plan/SKILL.md +53 -0
  58. package/src/plugin/skills/plan/model-prompt.critique.md +44 -0
  59. package/src/plugin/skills/plan/model-prompt.md +59 -0
  60. package/src/plugin/skills/plan/reference/tech-context.md +56 -0
  61. package/src/plugin/skills/ready/SKILL.md +44 -0
  62. package/src/plugin/skills/rfc/SKILL.md +47 -0
  63. package/src/plugin/skills/setup/SKILL.md +67 -0
  64. package/src/plugin/skills/spec/SKILL.md +37 -0
  65. package/src/plugin/skills/spec/model-prompt.md +61 -0
  66. package/src/plugin/skills/story/SKILL.md +49 -0
  67. package/src/plugin/skills/task/SKILL.md +41 -0
  68. package/src/plugin/skills/triage/SKILL.md +52 -0
  69. package/src/plugin/skills/uninstall/SKILL.md +43 -0
  70. package/src/plugin/skills/update/SKILL.md +51 -0
  71. package/src/plugin/skills/workflow/SKILL.md +154 -0
  72. package/src/templates/skill/SKILL.md +54 -4
  73. package/src/templates/workflows/generate-bug.yml +36 -0
  74. package/src/templates/workflows/validate.yml +2 -1
  75. package/src/ui/wizard.mjs +5 -2
@@ -0,0 +1,452 @@
1
+ // Implementações in-process de Read / Write / Glob / Grep.
2
+ //
3
+ // Porte de `agent-cli/src/tools.ts` (TypeScript) para ESM puro — este pacote
4
+ // não tem etapa de build, então tipos viram JSDoc e nada mais muda.
5
+ //
6
+ // Servem o backend OpenRouter, que não tem subprocesso do Claude Code para
7
+ // executar tools por ele. O backend anthropic usa as tools do próprio SDK
8
+ // dentro do subprocesso e só consome o `ENV_FILE_PATTERN` daqui.
9
+ //
10
+ // Toda garantia que o subprocesso dá de graça é reimplementada aqui: `.env*` é
11
+ // ilegível e inescrevível, caminhos ficam confinados às roots do run, e o Write
12
+ // obedece à allowlist. O modelo nunca recebe um caminho que passou por fora
13
+ // dessas checagens.
14
+
15
+ import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ // Casa ".env", ".env.local", ".env.production" em qualquer ponto do caminho,
19
+ // mas não arquivos que apenas contenham ".env" como substring ("envelope.ts").
20
+ export const ENV_FILE_PATTERN = /(^|[\\/])\.env(\.|$)/;
21
+
22
+ const MAX_READ_LINES = 2_000;
23
+ const MAX_LINE_CHARS = 2_000;
24
+ const MAX_OUTPUT_CHARS = 60_000;
25
+ const MAX_GLOB_RESULTS = 200;
26
+ const MAX_GREP_RESULTS = 200;
27
+ const MAX_GREP_LINE_CHARS = 300;
28
+ const MAX_GREP_FILE_BYTES = 2_000_000;
29
+ const MAX_WALK_FILES = 20_000;
30
+ const MAX_WALK_DEPTH = 12;
31
+
32
+ // Diretórios que nunca valem a caminhada e dominariam qualquer resultado.
33
+ // Deliberadamente curto — o resto é jogo justo.
34
+ const SKIP_DIRS = new Set([
35
+ '.cache', '.git', '.next', '.venv', '__pycache__',
36
+ 'build', 'coverage', 'dist', 'node_modules', 'vendor',
37
+ ]);
38
+
39
+ /**
40
+ * @typedef {object} ToolContext
41
+ * @property {string} cwd base dos caminhos relativos e root primária
42
+ * @property {string[]} tools superfície de tools do run — validada na EXECUÇÃO,
43
+ * não só no que o modelo vê: chamada alucinada a tool fora da lista é negada
44
+ * @property {string[]|null} allowedWritePaths null = qualquer caminho gravável
45
+ * dentro de `roots`; senão o Write só passa nos caminhos listados
46
+ * @property {string[]} roots roots absolutas; todo caminho resolve dentro de uma
47
+ * @property {number} denials contador de chamadas recusadas
48
+ */
49
+
50
+ /**
51
+ * @typedef {object} ToolResult
52
+ * @property {string} output
53
+ * @property {boolean} isError
54
+ */
55
+
56
+ /** Forma de comparação: absoluto resolvido, minúsculo no win32 (NTFS ignora caixa). */
57
+ function normalize(p) {
58
+ const resolved = path.resolve(p);
59
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
60
+ }
61
+
62
+ function isInside(child, parent) {
63
+ const rel = path.relative(parent, child);
64
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
65
+ }
66
+
67
+ /**
68
+ * @param {{ tools: string[], cwd?: string, allowedWritePaths?: string[] }} config
69
+ * @returns {ToolContext}
70
+ */
71
+ export function createToolContext(config) {
72
+ const cwd = path.resolve(config.cwd ?? process.cwd());
73
+ const allowed = config.allowedWritePaths?.map(p => path.resolve(p)) ?? null;
74
+ // Um alvo de escrita pode ficar fora do cwd; nesse caso o diretório pai dele
75
+ // também precisa ser root, espelhando `additionalDirectories` no anthropic.
76
+ const roots = [...new Set([cwd, ...(allowed ?? []).map(p => path.dirname(p))])];
77
+ return {
78
+ cwd,
79
+ tools: [...config.tools],
80
+ allowedWritePaths: allowed === null ? null : allowed.map(normalize),
81
+ roots,
82
+ denials: 0,
83
+ };
84
+ }
85
+
86
+ // "denied" é recusa de política (conta em ctx.denials); "invalid" é só argumento
87
+ // malformado — a diferença importa para o relatório do run.
88
+ function resolveToolPath(ctx, raw, param) {
89
+ if (typeof raw !== 'string' || raw.trim() === '') {
90
+ return { ok: false, reason: 'invalid', error: `${param} must be a non-empty string` };
91
+ }
92
+ const abs = path.resolve(ctx.cwd, raw);
93
+ if (ENV_FILE_PATTERN.test(abs)) {
94
+ return { ok: false, reason: 'denied', error: 'Access to .env files is blocked' };
95
+ }
96
+ if (!ctx.roots.some(root => isInside(abs, root))) {
97
+ return {
98
+ ok: false,
99
+ reason: 'denied',
100
+ error: `Path is outside the allowed working directory (${ctx.roots.join(', ')})`,
101
+ };
102
+ }
103
+ return { ok: true, abs };
104
+ }
105
+
106
+ function pathFailure(ctx, check) {
107
+ return check.reason === 'denied' ? deny(ctx, check.error) : fail(check.error);
108
+ }
109
+
110
+ function deny(ctx, message) {
111
+ ctx.denials += 1;
112
+ return { output: message, isError: true };
113
+ }
114
+
115
+ function fail(message) {
116
+ return { output: message, isError: true };
117
+ }
118
+
119
+ function truncate(text, limit) {
120
+ return text.length > limit ? `${text.slice(0, limit)}… [truncated]` : text;
121
+ }
122
+
123
+ function errorMessage(err) {
124
+ return err instanceof Error ? err.message : String(err);
125
+ }
126
+
127
+ function escapeRegExp(text) {
128
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
129
+ }
130
+
131
+ /**
132
+ * Suporta `**` (qualquer profundidade), `*` e `?` (dentro de um segmento) e
133
+ * alternância simples não-aninhada `{a,b}`. Casado contra um caminho separado
134
+ * por "/" relativo à raiz da busca (função PURA).
135
+ */
136
+ export function globToRegExp(pattern) {
137
+ let source = '';
138
+ for (let i = 0; i < pattern.length; i++) {
139
+ const ch = pattern[i];
140
+ if (ch === '*') {
141
+ if (pattern[i + 1] === '*') {
142
+ i++;
143
+ if (pattern[i + 1] === '/') {
144
+ i++;
145
+ source += '(?:[^/]+/)*';
146
+ } else {
147
+ source += '.*';
148
+ }
149
+ } else {
150
+ source += '[^/]*';
151
+ }
152
+ } else if (ch === '?') {
153
+ source += '[^/]';
154
+ } else if (ch === '{') {
155
+ const close = pattern.indexOf('}', i);
156
+ if (close === -1) {
157
+ source += '\\{';
158
+ } else {
159
+ source += `(?:${pattern.slice(i + 1, close).split(',').map(escapeRegExp).join('|')})`;
160
+ i = close;
161
+ }
162
+ } else {
163
+ source += escapeRegExp(ch);
164
+ }
165
+ }
166
+ return new RegExp(`^${source}$`);
167
+ }
168
+
169
+ /** Caminhada limitada por profundidade, contagem e skip-list. Nunca produz `.env*`. */
170
+ function* iterFiles(root) {
171
+ const stack = [{ dir: root, depth: 0 }];
172
+ let seen = 0;
173
+ while (stack.length > 0) {
174
+ const current = stack.pop();
175
+ if (!current) break;
176
+ let entries;
177
+ try {
178
+ entries = readdirSync(current.dir, { withFileTypes: true });
179
+ } catch {
180
+ continue; // diretório ilegível — pula, não derruba a tool inteira
181
+ }
182
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
183
+ for (const entry of entries) {
184
+ const abs = path.join(current.dir, entry.name);
185
+ if (entry.isDirectory()) {
186
+ if (SKIP_DIRS.has(entry.name) || current.depth >= MAX_WALK_DEPTH) continue;
187
+ stack.push({ dir: abs, depth: current.depth + 1 });
188
+ } else if (entry.isFile()) {
189
+ if (ENV_FILE_PATTERN.test(abs)) continue;
190
+ if (++seen > MAX_WALK_FILES) return;
191
+ yield { abs, rel: path.relative(root, abs).split(path.sep).join('/') };
192
+ }
193
+ }
194
+ }
195
+ }
196
+
197
+ function readTool(ctx, input) {
198
+ const checked = resolveToolPath(ctx, input.file_path, 'file_path');
199
+ if (!checked.ok) return pathFailure(ctx, checked);
200
+
201
+ let stat;
202
+ try {
203
+ stat = statSync(checked.abs);
204
+ } catch (err) {
205
+ return fail(`Could not read ${checked.abs}: ${errorMessage(err)}`);
206
+ }
207
+ if (stat.isDirectory()) {
208
+ return fail(`${checked.abs} is a directory — use Glob to list its files`);
209
+ }
210
+
211
+ let content;
212
+ try {
213
+ content = readFileSync(checked.abs, 'utf8');
214
+ } catch (err) {
215
+ return fail(`Could not read ${checked.abs}: ${errorMessage(err)}`);
216
+ }
217
+ if (content.includes('\0')) return fail(`${checked.abs} looks like a binary file`);
218
+
219
+ const offset = typeof input.offset === 'number' && input.offset >= 1
220
+ ? Math.floor(input.offset)
221
+ : 1;
222
+ const limit = typeof input.limit === 'number' && input.limit >= 1
223
+ ? Math.min(Math.floor(input.limit), MAX_READ_LINES)
224
+ : MAX_READ_LINES;
225
+
226
+ const lines = content.split('\n');
227
+ const slice = lines.slice(offset - 1, offset - 1 + limit);
228
+ if (slice.length === 0) {
229
+ return fail(`offset ${offset} is past the end of the file (${lines.length} lines)`);
230
+ }
231
+ const numbered = slice
232
+ .map((line, i) => `${offset + i}\t${truncate(line, MAX_LINE_CHARS)}`)
233
+ .join('\n');
234
+ const remaining = lines.length - (offset - 1 + slice.length);
235
+ const footer = remaining > 0 ? `\n… ${remaining} more line(s); re-read with offset` : '';
236
+ return { output: truncate(numbered, MAX_OUTPUT_CHARS) + footer, isError: false };
237
+ }
238
+
239
+ function writeTool(ctx, input) {
240
+ const checked = resolveToolPath(ctx, input.file_path, 'file_path');
241
+ if (!checked.ok) return pathFailure(ctx, checked);
242
+ if (ctx.allowedWritePaths !== null && !ctx.allowedWritePaths.includes(normalize(checked.abs))) {
243
+ return deny(ctx, `Writes are allowed only to: ${ctx.allowedWritePaths.join(', ')}`);
244
+ }
245
+ if (typeof input.content !== 'string') return fail('content must be a string');
246
+ try {
247
+ mkdirSync(path.dirname(checked.abs), { recursive: true });
248
+ writeFileSync(checked.abs, input.content, 'utf8');
249
+ } catch (err) {
250
+ return fail(`Could not write ${checked.abs}: ${errorMessage(err)}`);
251
+ }
252
+ return {
253
+ output: `Wrote ${Buffer.byteLength(input.content, 'utf8')} bytes to ${checked.abs}`,
254
+ isError: false,
255
+ };
256
+ }
257
+
258
+ function globTool(ctx, input) {
259
+ if (typeof input.pattern !== 'string' || input.pattern.trim() === '') {
260
+ return fail('pattern must be a non-empty string');
261
+ }
262
+ const baseCheck = resolveToolPath(ctx, input.path ?? ctx.cwd, 'path');
263
+ if (!baseCheck.ok) return pathFailure(ctx, baseCheck);
264
+ try {
265
+ if (!statSync(baseCheck.abs).isDirectory()) return fail(`${baseCheck.abs} is not a directory`);
266
+ } catch (err) {
267
+ return fail(`Could not search ${baseCheck.abs}: ${errorMessage(err)}`);
268
+ }
269
+
270
+ const matcher = globToRegExp(input.pattern);
271
+ const matches = [];
272
+ let truncated = false;
273
+ for (const file of iterFiles(baseCheck.abs)) {
274
+ if (!matcher.test(file.rel)) continue;
275
+ if (matches.length >= MAX_GLOB_RESULTS) {
276
+ truncated = true;
277
+ break;
278
+ }
279
+ matches.push(file.abs);
280
+ }
281
+ if (matches.length === 0) return { output: `No files matched ${input.pattern}`, isError: false };
282
+ const footer = truncated ? `\n… more matches omitted (showing first ${MAX_GLOB_RESULTS})` : '';
283
+ return { output: matches.join('\n') + footer, isError: false };
284
+ }
285
+
286
+ function grepTool(ctx, input) {
287
+ if (typeof input.pattern !== 'string' || input.pattern === '') {
288
+ return fail('pattern must be a non-empty string');
289
+ }
290
+ let regex;
291
+ try {
292
+ regex = new RegExp(input.pattern, input.case_insensitive === true ? 'i' : '');
293
+ } catch (err) {
294
+ return fail(`Invalid regular expression: ${errorMessage(err)}`);
295
+ }
296
+
297
+ const baseCheck = resolveToolPath(ctx, input.path ?? ctx.cwd, 'path');
298
+ if (!baseCheck.ok) return pathFailure(ctx, baseCheck);
299
+ let baseIsDir;
300
+ try {
301
+ baseIsDir = statSync(baseCheck.abs).isDirectory();
302
+ } catch (err) {
303
+ return fail(`Could not search ${baseCheck.abs}: ${errorMessage(err)}`);
304
+ }
305
+
306
+ const fileFilter = typeof input.glob === 'string' && input.glob.trim() !== ''
307
+ ? globToRegExp(input.glob)
308
+ : null;
309
+ const files = baseIsDir
310
+ ? iterFiles(baseCheck.abs)
311
+ : [{ abs: baseCheck.abs, rel: path.basename(baseCheck.abs) }];
312
+
313
+ const hits = [];
314
+ let truncated = false;
315
+ for (const file of files) {
316
+ if (fileFilter && !fileFilter.test(file.rel)) continue;
317
+ try {
318
+ if (statSync(file.abs).size > MAX_GREP_FILE_BYTES) continue;
319
+ } catch {
320
+ continue;
321
+ }
322
+ let content;
323
+ try {
324
+ content = readFileSync(file.abs, 'utf8');
325
+ } catch {
326
+ continue;
327
+ }
328
+ if (content.slice(0, 8_000).includes('\0')) continue; // binário
329
+ const lines = content.split('\n');
330
+ for (let i = 0; i < lines.length; i++) {
331
+ if (!regex.test(lines[i])) continue;
332
+ if (hits.length >= MAX_GREP_RESULTS) {
333
+ truncated = true;
334
+ break;
335
+ }
336
+ hits.push(`${file.abs}:${i + 1}:${truncate(lines[i].trim(), MAX_GREP_LINE_CHARS)}`);
337
+ }
338
+ if (truncated) break;
339
+ }
340
+
341
+ if (hits.length === 0) return { output: `No matches for ${input.pattern}`, isError: false };
342
+ const footer = truncated ? `\n… more matches omitted (showing first ${MAX_GREP_RESULTS})` : '';
343
+ return { output: hits.join('\n') + footer, isError: false };
344
+ }
345
+
346
+ const IMPLEMENTATIONS = { Read: readTool, Write: writeTool, Glob: globTool, Grep: grepTool };
347
+
348
+ const DEFINITIONS = {
349
+ Read: {
350
+ type: 'function',
351
+ function: {
352
+ name: 'Read',
353
+ description:
354
+ 'Read a text file from the local filesystem. Returns the content with 1-based line numbers. Use offset/limit to page through large files.',
355
+ parameters: {
356
+ type: 'object',
357
+ properties: {
358
+ file_path: { type: 'string', description: 'Path to the file (absolute, or relative to the working directory).' },
359
+ offset: { type: 'integer', description: '1-based line number to start from. Defaults to 1.' },
360
+ limit: { type: 'integer', description: `Maximum number of lines to return (cap ${MAX_READ_LINES}).` },
361
+ },
362
+ required: ['file_path'],
363
+ additionalProperties: false,
364
+ },
365
+ },
366
+ },
367
+ Write: {
368
+ type: 'function',
369
+ function: {
370
+ name: 'Write',
371
+ description:
372
+ 'Write text to a file, creating it (and any missing parent directories) or overwriting it completely. Prefer reading a file before overwriting it.',
373
+ parameters: {
374
+ type: 'object',
375
+ properties: {
376
+ file_path: { type: 'string', description: 'Path to the file (absolute, or relative to the working directory).' },
377
+ content: { type: 'string', description: 'Full content to write.' },
378
+ },
379
+ required: ['file_path', 'content'],
380
+ additionalProperties: false,
381
+ },
382
+ },
383
+ },
384
+ Glob: {
385
+ type: 'function',
386
+ function: {
387
+ name: 'Glob',
388
+ description:
389
+ "Find files by path pattern. Supports ** (any depth), * and ? (within a path segment) and {a,b} alternation, e.g. '**/*.{ts,tsx}'. Returns absolute paths.",
390
+ parameters: {
391
+ type: 'object',
392
+ properties: {
393
+ pattern: { type: 'string', description: 'Glob pattern, matched against paths relative to the search directory.' },
394
+ path: { type: 'string', description: 'Directory to search. Defaults to the working directory.' },
395
+ },
396
+ required: ['pattern'],
397
+ additionalProperties: false,
398
+ },
399
+ },
400
+ },
401
+ Grep: {
402
+ type: 'function',
403
+ function: {
404
+ name: 'Grep',
405
+ description:
406
+ "Search file contents with a JavaScript regular expression. Returns matching lines as 'path:line:text'.",
407
+ parameters: {
408
+ type: 'object',
409
+ properties: {
410
+ pattern: { type: 'string', description: 'Regular expression to search for.' },
411
+ path: { type: 'string', description: 'File or directory to search. Defaults to the working directory.' },
412
+ glob: { type: 'string', description: "Only search files whose relative path matches this glob, e.g. '**/*.ts'." },
413
+ case_insensitive: { type: 'boolean', description: 'Match case-insensitively. Defaults to false.' },
414
+ },
415
+ required: ['pattern'],
416
+ additionalProperties: false,
417
+ },
418
+ },
419
+ },
420
+ };
421
+
422
+ export const KNOWN_TOOL_NAMES = Object.keys(DEFINITIONS);
423
+
424
+ /** Definições em formato OpenAI para a superfície pedida; nomes desconhecidos caem fora. */
425
+ export function toolDefinitionsFor(names) {
426
+ return names.map(name => DEFINITIONS[name]).filter(def => def !== undefined);
427
+ }
428
+
429
+ /**
430
+ * Roda UMA chamada de tool. Nunca lança: toda falha — caminho negado, argumento
431
+ * ruim, erro de filesystem — volta como `{ isError: true }` para o modelo ver e
432
+ * se adaptar.
433
+ *
434
+ * @param {string} name
435
+ * @param {unknown} input
436
+ * @param {ToolContext} ctx
437
+ * @returns {ToolResult}
438
+ */
439
+ export function executeTool(name, input, ctx) {
440
+ const impl = IMPLEMENTATIONS[name];
441
+ if (!impl || !ctx.tools.includes(name)) {
442
+ return deny(ctx, `Tool "${name}" is not available in this run. Available: ${ctx.tools.join(', ')}`);
443
+ }
444
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
445
+ return fail('Tool arguments must be a JSON object');
446
+ }
447
+ try {
448
+ return impl(ctx, input);
449
+ } catch (err) {
450
+ return fail(`${name} failed: ${errorMessage(err)}`);
451
+ }
452
+ }
@@ -0,0 +1,106 @@
1
+ // Fachada sobre `@langfuse/tracing`, para que o tracing seja OPCIONAL.
2
+ //
3
+ // No agent-cli a telemetria é premissa: o binário existe para ser observado.
4
+ // Aqui não — `generate-spec` roda dentro de um GitHub Action de quem talvez
5
+ // nunca tenha ouvido falar de Langfuse, e a geração do documento não pode
6
+ // depender disso. Sem chaves configuradas, tudo aqui vira no-op e os backends
7
+ // seguem idênticos.
8
+ //
9
+ // O contrato dos backends fica limpo: eles chamam `withTrace`/`observe` sem
10
+ // nunca perguntar se há telemetria.
11
+
12
+ import { telemetryConfigured } from './telemetry.mjs';
13
+
14
+ // Observação nula: mesma superfície do objeto do Langfuse, sem efeito.
15
+ const NOOP_OBSERVATION = {
16
+ update() {},
17
+ end() {},
18
+ startObservation: () => NOOP_OBSERVATION,
19
+ };
20
+
21
+ let tracingModule;
22
+
23
+ // Import dinâmico: sem telemetria configurada o pacote nem é carregado, o que
24
+ // tira ~centenas de ms do arranque de todo comando que não gera documento.
25
+ async function loadTracing() {
26
+ if (!telemetryConfigured()) return null;
27
+ if (tracingModule === undefined) {
28
+ try {
29
+ tracingModule = await import('@langfuse/tracing');
30
+ } catch {
31
+ tracingModule = null;
32
+ }
33
+ }
34
+ return tracingModule;
35
+ }
36
+
37
+ /**
38
+ * Roda `fn` dentro de uma observação raiz, propagando os atributos da trace.
39
+ *
40
+ * Sem telemetria, chama `fn` com uma observação nula — o backend não muda de
41
+ * caminho de código, então a versão instrumentada e a não-instrumentada não
42
+ * divergem em comportamento.
43
+ *
44
+ * @param {object} params
45
+ * @param {string} params.name nome da observação raiz
46
+ * @param {string} params.sessionId
47
+ * @param {string} params.userId
48
+ * @param {string[]} params.tags
49
+ * @param {object} [params.metadata]
50
+ * @param {boolean} [params.nested] herda o contexto ambiente em vez de abrir trace
51
+ * @param {(span: object) => Promise<any>} fn
52
+ */
53
+ export async function withTrace(params, fn) {
54
+ const lf = await loadTracing();
55
+ if (!lf) return fn(NOOP_OBSERVATION);
56
+
57
+ const execute = () =>
58
+ lf.startActiveObservation(params.name, span => fn(span), { asType: 'agent' });
59
+
60
+ if (params.nested === true) return execute();
61
+
62
+ return lf.propagateAttributes(
63
+ {
64
+ sessionId: params.sessionId,
65
+ userId: params.userId,
66
+ tags: params.tags,
67
+ ...(params.metadata ? { metadata: params.metadata } : {}),
68
+ },
69
+ execute,
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Abre uma observação filha sem nunca lançar.
75
+ *
76
+ * A telemetria não pode quebrar o run — é a invariante que o agent-cli mantém
77
+ * com try/catch em cada hook. Centralizando aqui, o corpo dos backends fica sem
78
+ * try/catch de tracing espalhado.
79
+ *
80
+ * @returns {{ update: Function, end: Function }} observação (real ou nula)
81
+ */
82
+ export function startObservation(span, name, body, asType) {
83
+ try {
84
+ return span.startObservation(name, body, { asType }) ?? NOOP_OBSERVATION;
85
+ } catch {
86
+ return NOOP_OBSERVATION;
87
+ }
88
+ }
89
+
90
+ /** `span.update` que nunca lança. */
91
+ export function updateSpan(span, body) {
92
+ try {
93
+ span.update(body);
94
+ } catch {
95
+ // telemetria nunca derruba o run
96
+ }
97
+ }
98
+
99
+ /** `observation.end` que nunca lança. */
100
+ export function endObservation(observation) {
101
+ try {
102
+ observation?.end();
103
+ } catch {
104
+ // telemetria nunca derruba o run
105
+ }
106
+ }
@@ -374,6 +374,14 @@ export async function updateComment(token, owner, repo, commentId, body) {
374
374
  });
375
375
  }
376
376
 
377
+ // Fecha (ou reabre) uma issue. Usado pela triagem de Bug: rejeitar e marcar
378
+ // como duplicata tiram o item das filas SEM mexer na Etapa — ela nunca
379
+ // retrocede, e um bug rejeitado não avançou para lugar nenhum.
380
+ export async function setIssueState(token, owner, repo, issueNumber, state) {
381
+ const octokit = makeOctokit(token);
382
+ await octokit.rest.issues.update({ owner, repo, issue_number: issueNumber, state });
383
+ }
384
+
377
385
  // Marca uma issue como bloqueada por outra (relação nativa do GitHub).
378
386
  // `blockingIssueId` é o DATABASE id da issue bloqueadora (não o number nem o
379
387
  // node id — ver createIssue). Erros propagam: o chamador usa como fallback.
@@ -0,0 +1,8 @@
1
+ import { issue } from './issue.mjs';
2
+
3
+ // `bug` é um atalho de `issue --type bug`, no mesmo padrão de `feature` e
4
+ // `initiative`. A etapa de nascimento (🐞 Triagem, ou ✅ Ready quando P0) é
5
+ // decidida por initialStageForType — ver config.mjs.
6
+ export async function bug(options) {
7
+ return issue({ ...options, type: 'bug' });
8
+ }
@@ -52,16 +52,40 @@ async function resolveFeatureIssue(token, owner, repo, issueNumber) {
52
52
  // tipo porque cada um tem destino próprio: Tasks → Done, Stories → Code Review,
53
53
  // Feature → Code Review (só quando todas as Stories estiverem prontas). Retorna
54
54
  // { feature:{number,nodeId,title}|null, stories:Map, tasks:Map }.
55
+ /**
56
+ * Como o PR trata uma issue referenciada, pelo tipo (função PURA).
57
+ *
58
+ * 'bug' → unidade de review PRÓPRIA: move o Bug e NÃO toca na Feature-pai.
59
+ * Um defeito em review não deve arrastar a Feature inteira para
60
+ * Code Review — ela pode ter Stories ainda em desenvolvimento.
61
+ * 'unit' → Feature/Story/Task: sobe pela cadeia até a Feature (fluxo atual).
62
+ * 'ignore' → Spike, RFC, Epic e desconhecidos.
63
+ *
64
+ * @param {string|null} type
65
+ * @returns {'bug'|'unit'|'ignore'}
66
+ */
67
+ export function classifyReviewTarget(type) {
68
+ if (type === 'Bug') return 'bug';
69
+ if (type === 'Feature' || type === 'Story' || type === 'Task') return 'unit';
70
+ return 'ignore';
71
+ }
72
+
55
73
  async function collectReviewUnit(token, owner, repo, issueNumber) {
56
74
  const stories = new Map();
57
75
  const tasks = new Map();
76
+ const bugs = new Map();
58
77
  const addStory = (n, nodeId, title) => { if (n && nodeId && !stories.has(n)) stories.set(n, { nodeId, title }); };
59
78
  const addTask = (n, nodeId, title) => { if (n && nodeId && !tasks.has(n)) tasks.set(n, { nodeId, title }); };
60
79
 
61
80
  let issue;
62
- try { issue = await getIssue(token, owner, repo, issueNumber); } catch { return { feature: null, stories, tasks }; }
81
+ try { issue = await getIssue(token, owner, repo, issueNumber); } catch { return { feature: null, stories, tasks, bugs }; }
63
82
  const type = detectIssueType(issue);
64
- if (type !== 'Feature' && type !== 'Story' && type !== 'Task') return { feature: null, stories, tasks };
83
+ const kind = classifyReviewTarget(type);
84
+ if (kind === 'ignore') return { feature: null, stories, tasks, bugs };
85
+ if (kind === 'bug') {
86
+ bugs.set(issue.number, { nodeId: issue.node_id, title: issue.title });
87
+ return { feature: null, stories, tasks, bugs };
88
+ }
65
89
 
66
90
  const featureIssue = await resolveFeatureIssue(token, owner, repo, issueNumber);
67
91
  const feature = featureIssue
@@ -93,7 +117,7 @@ async function collectReviewUnit(token, owner, repo, issueNumber) {
93
117
  for (const t of tks) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
94
118
  }
95
119
  }
96
- return { feature, stories, tasks };
120
+ return { feature, stories, tasks, bugs };
97
121
  }
98
122
 
99
123
  // A Feature só avança quando TODAS as suas Stories já estiverem em Code Review
@@ -155,7 +179,7 @@ export async function codeReview({ prNumber }) {
155
179
  // Stories → 👀 Code Review; Feature → Code Review só quando TODAS as suas
156
180
  // Stories já estiverem em Code Review.
157
181
  for (const num of issueNums) {
158
- const { feature, stories, tasks } = await collectReviewUnit(token, owner, repo, num);
182
+ const { feature, stories, tasks, bugs } = await collectReviewUnit(token, owner, repo, num);
159
183
 
160
184
  // Tasks → Done (Status Done).
161
185
  for (const [n, info] of tasks) {
@@ -191,6 +215,23 @@ export async function codeReview({ prNumber }) {
191
215
  }
192
216
  }
193
217
 
218
+ // Bugs → Code Review, sem Feature-pai envolvida.
219
+ for (const [n, info] of bugs) {
220
+ if (seen.has(n)) continue;
221
+ seen.add(n);
222
+ try {
223
+ const moved = await advanceToStage(projectToken, project, etapaField, statusField, info.nodeId, CODE_REVIEW_STAGE, TODO_STATUS);
224
+ if (moved) {
225
+ updated.push(`#${n} ${info.title} → ${CODE_REVIEW_STAGE}`);
226
+ console.log(`Bug #${n} → "${CODE_REVIEW_STAGE}" / Status "${TODO_STATUS}".`);
227
+ } else {
228
+ console.log(`Bug #${n} já está em "${CODE_REVIEW_STAGE}" ou etapa posterior — mantido (não retrocede).`);
229
+ }
230
+ } catch (err) {
231
+ console.warn(`Falha ao atualizar #${n}: ${err.message}`);
232
+ }
233
+ }
234
+
194
235
  // Feature: só avança se todas as suas Stories já estão em Code Review+.
195
236
  if (feature && !featuresChecked.has(feature.number)) {
196
237
  featuresChecked.add(feature.number);