@spec-wave/cli 0.14.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.
- package/README.md +1 -0
- package/bin/spec-wave.mjs +44 -2
- package/package.json +8 -2
- package/src/agent/anthropic-agent.mjs +337 -0
- package/src/agent/errors.mjs +33 -0
- package/src/agent/index.mjs +108 -0
- package/src/agent/openrouter-agent.mjs +378 -0
- package/src/agent/run-types.mjs +59 -0
- package/src/agent/telemetry.mjs +54 -0
- package/src/agent/tools.mjs +452 -0
- package/src/agent/tracing.mjs +106 -0
- package/src/api/github-rest.mjs +206 -2
- package/src/commands/bug.mjs +8 -0
- package/src/commands/code-review.mjs +45 -4
- package/src/commands/decompose.mjs +11 -49
- package/src/commands/dev-agent.mjs +3 -3
- package/src/commands/doctor.mjs +77 -6
- package/src/commands/generate-bug.mjs +195 -0
- package/src/commands/generate-plan.mjs +6 -20
- package/src/commands/generate-spec.mjs +6 -22
- package/src/commands/implement.mjs +105 -2
- package/src/commands/init.mjs +3 -3
- package/src/commands/install-skill.mjs +72 -16
- package/src/commands/issue.mjs +9 -7
- package/src/commands/move.mjs +11 -1
- package/src/commands/qa.mjs +23 -2
- package/src/commands/refresh.mjs +145 -5
- package/src/commands/triage.mjs +174 -0
- package/src/commands/update.mjs +352 -62
- package/src/commands/validate.mjs +82 -10
- package/src/config.mjs +159 -1
- package/src/lib/bug-context.mjs +160 -0
- package/src/lib/bug-doc.mjs +51 -0
- package/src/lib/bug-triage.mjs +81 -0
- package/src/lib/claude.mjs +71 -254
- package/src/lib/critique.mjs +43 -30
- package/src/lib/implement-board.mjs +12 -1
- package/src/lib/plugin-skills.mjs +122 -0
- package/src/lib/pr-branch.mjs +267 -0
- package/src/lib/prompt-loader.mjs +257 -0
- package/src/lib/skill-file.mjs +35 -0
- package/src/plugin/.claude-plugin/plugin.json +20 -0
- package/src/plugin/README.md +73 -0
- package/src/plugin/skills/bug/SKILL.md +60 -0
- package/src/plugin/skills/bug/model-prompt.critique.md +48 -0
- package/src/plugin/skills/bug/model-prompt.md +74 -0
- package/src/plugin/skills/decompose/SKILL.md +111 -0
- package/src/plugin/skills/decompose/model-prompt.critique.md +46 -0
- package/src/plugin/skills/decompose/model-prompt.feature.md +69 -0
- package/src/plugin/skills/decompose/model-prompt.rfc.md +52 -0
- package/src/plugin/skills/doctor/SKILL.md +51 -0
- package/src/plugin/skills/fix-pr/SKILL.md +130 -0
- package/src/plugin/skills/implement/SKILL.md +102 -0
- package/src/plugin/skills/info/SKILL.md +40 -0
- package/src/plugin/skills/issue/SKILL.md +63 -0
- package/src/plugin/skills/move/SKILL.md +52 -0
- package/src/plugin/skills/order/SKILL.md +36 -0
- package/src/plugin/skills/plan/SKILL.md +53 -0
- package/src/plugin/skills/plan/model-prompt.critique.md +44 -0
- package/src/plugin/skills/plan/model-prompt.md +59 -0
- package/src/plugin/skills/plan/reference/tech-context.md +56 -0
- package/src/plugin/skills/ready/SKILL.md +44 -0
- package/src/plugin/skills/rfc/SKILL.md +47 -0
- package/src/plugin/skills/setup/SKILL.md +67 -0
- package/src/plugin/skills/spec/SKILL.md +37 -0
- package/src/plugin/skills/spec/model-prompt.md +61 -0
- package/src/plugin/skills/story/SKILL.md +49 -0
- package/src/plugin/skills/task/SKILL.md +41 -0
- package/src/plugin/skills/triage/SKILL.md +52 -0
- package/src/plugin/skills/uninstall/SKILL.md +43 -0
- package/src/plugin/skills/update/SKILL.md +51 -0
- package/src/plugin/skills/workflow/SKILL.md +154 -0
- package/src/templates/skill/SKILL.md +69 -7
- package/src/templates/workflows/generate-bug.yml +36 -0
- package/src/templates/workflows/validate.yml +2 -1
- 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
|
+
}
|