workflow-ai 1.0.62 → 1.0.63
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 +61 -0
- package/agent-templates/CLAUDE.md.tpl +2 -0
- package/agent-templates/QWEN.md.tpl +2 -0
- package/package.json +2 -1
- package/src/init.mjs +5 -4
- package/src/lib/agent-spawner.mjs +338 -0
- package/src/runner.mjs +15 -14
- package/src/scripts/get-next-test-id.js +94 -0
- package/src/scripts/migrate-backlog-to-tests.js +406 -0
- package/src/scripts/run-skill-tests.js +1491 -0
- package/src/scripts/scan-fixtures-for-secrets.js +248 -0
- package/src/scripts/tests/timeout-cascade.test.js +28 -0
- package/templates/plan-template.md +1 -0
package/README.md
CHANGED
|
@@ -121,6 +121,67 @@ Skills are stored globally in `~/.workflow/skills/` and linked into projects via
|
|
|
121
121
|
|
|
122
122
|
Use `workflow eject <skill>` to copy a skill into the project for customization.
|
|
123
123
|
|
|
124
|
+
## Skill regression tests
|
|
125
|
+
|
|
126
|
+
Трёхуровневая система тестирования скилов для проверки качества AI-агентов.
|
|
127
|
+
|
|
128
|
+
### Три слоя тестирования
|
|
129
|
+
|
|
130
|
+
| Level | Name | Description |
|
|
131
|
+
|-------|------|-------------|
|
|
132
|
+
| L0 | Static | Базовая проверка синтаксиса и структуры: YAML-валидация, проверка обязательных полей, линтер |
|
|
133
|
+
| L1 | Deterministic | Детерминированные тесты: эталонные входные данные → ожидаемый результат (strict match) |
|
|
134
|
+
| L2 | Rubric | Гибкая оценка по критериям: scorer выставляет баллы на основе качества результата |
|
|
135
|
+
|
|
136
|
+
### Структура директорий
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
src/skills/<name>/tests/
|
|
140
|
+
├── index.yaml # Метаданные тестов, список test cases
|
|
141
|
+
├── cases/ # Входные данные для тестов
|
|
142
|
+
│ └── <case-id>/
|
|
143
|
+
│ └── input.yaml
|
|
144
|
+
├── fixtures/ # Ожидаемые выходные данные (для L1)
|
|
145
|
+
│ └── <case-id>/
|
|
146
|
+
│ └── expected.yaml
|
|
147
|
+
└── rubrics/ # Критерии оценки (для L2)
|
|
148
|
+
└── <case-id>/
|
|
149
|
+
└── rubric.yaml
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Запуск тестов
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
npm run test:skills
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### CLI-флаги
|
|
159
|
+
|
|
160
|
+
| Flag | Description |
|
|
161
|
+
|------|-------------|
|
|
162
|
+
| `--skill <name>` | Запустить тесты только для указанного скила |
|
|
163
|
+
| `--relevant` | Запустить только тесты, соответствующие изменённым файлам |
|
|
164
|
+
| `--establish-baseline` | Запустить тесты и сохранить результаты как baseline |
|
|
165
|
+
| `--baseline-ref <ref>` | Использовать конкретный baseline (коммит, тег) |
|
|
166
|
+
| `--yes` | Автоматически подтверждать все действия |
|
|
167
|
+
|
|
168
|
+
### Verdict-режимы
|
|
169
|
+
|
|
170
|
+
| Mode | Description |
|
|
171
|
+
|------|-------------|
|
|
172
|
+
| `no-baseline` | Первый запуск — результаты сохраняются как baseline без сравнения |
|
|
173
|
+
| `no-regression` | Сравнение с baseline — тест считается пройденным, если результат не хуже baseline |
|
|
174
|
+
|
|
175
|
+
### Принцип git write
|
|
176
|
+
|
|
177
|
+
Runner и коуч **не выполняют git write-операций**. Все изменения в кодовой базе делает исключительно пользователь. Runner только анализирует и рекомендует, но не коммитит.
|
|
178
|
+
|
|
179
|
+
### First run on a new project
|
|
180
|
+
|
|
181
|
+
1. Запустить тесты с флагом `--establish-baseline`
|
|
182
|
+
2. Проверить результаты: красные тесты — ожидаемы для нового проекта
|
|
183
|
+
3. Зафиксировать baseline: `git commit current/` как baseline-коммит
|
|
184
|
+
|
|
124
185
|
## Scripts
|
|
125
186
|
|
|
126
187
|
Scripts are stored globally in `~/.workflow/scripts/` and linked as a single junction into `.workflow/src/scripts/`.
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
| Выбор следующей задачи | `node .workflow/src/scripts/pick-next-task.js` |
|
|
22
22
|
| Перемещение готовых в ready | `node .workflow/src/scripts/move-to-ready.js` |
|
|
23
23
|
|
|
24
|
+
**Регрессионное тестирование скилов:** `node .workflow/src/scripts/run-skill-tests.js --skill <name>`. Подробности — в `.workflow/src/skills/<name>/tests/index.yaml`.
|
|
25
|
+
|
|
24
26
|
### Кастомизация (eject)
|
|
25
27
|
|
|
26
28
|
| Действие | Команда |
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
| Выбор следующей задачи | `node .workflow/src/scripts/pick-next-task.js` |
|
|
22
22
|
| Перемещение готовых в ready | `node .workflow/src/scripts/move-to-ready.js` |
|
|
23
23
|
|
|
24
|
+
**Регрессионное тестирование скилов:** `node .workflow/src/scripts/run-skill-tests.js --skill <name>`. Подробности — в `.workflow/src/skills/<name>/tests/index.yaml`.
|
|
25
|
+
|
|
24
26
|
### Кастомизация (eject)
|
|
25
27
|
|
|
26
28
|
| Действие | Команда |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "workflow-ai",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.63",
|
|
4
4
|
"description": "AI Agent Workflow Coordinator — kanban-based pipeline for AI coding agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"test": "node --test src/tests/*.test.mjs",
|
|
35
|
+
"test:skills": "node src/scripts/run-skill-tests.js --all",
|
|
35
36
|
"release": "npm version patch && npm publish"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
package/src/init.mjs
CHANGED
|
@@ -357,7 +357,7 @@ export function initProject(targetPath = process.cwd(), options = {}) {
|
|
|
357
357
|
errors: []
|
|
358
358
|
};
|
|
359
359
|
|
|
360
|
-
// Step 1: Create .workflow/ structure (
|
|
360
|
+
// Step 1: Create .workflow/ structure (16 directories)
|
|
361
361
|
const directories = [
|
|
362
362
|
'tickets/backlog',
|
|
363
363
|
'tickets/ready',
|
|
@@ -370,13 +370,14 @@ export function initProject(targetPath = process.cwd(), options = {}) {
|
|
|
370
370
|
'reports',
|
|
371
371
|
'logs',
|
|
372
372
|
'templates',
|
|
373
|
-
'src/skills'
|
|
373
|
+
'src/skills',
|
|
374
|
+
'tests/skills'
|
|
374
375
|
];
|
|
375
|
-
|
|
376
|
+
|
|
376
377
|
for (const dir of directories) {
|
|
377
378
|
ensureDir(join(workflowRoot, dir));
|
|
378
379
|
}
|
|
379
|
-
result.steps.push('Created .workflow/ directory structure (
|
|
380
|
+
result.steps.push('Created .workflow/ directory structure (16 directories)');
|
|
380
381
|
|
|
381
382
|
// Step 2: Ensure global dir and create skill junctions
|
|
382
383
|
const globalDir = getGlobalDir();
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn, execSync } from 'child_process';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
const ResultParser = {
|
|
7
|
+
STATUS_ALIASES: {
|
|
8
|
+
pass: 'passed',
|
|
9
|
+
approved: 'passed',
|
|
10
|
+
success: 'passed',
|
|
11
|
+
succeeded: 'passed',
|
|
12
|
+
ok: 'passed',
|
|
13
|
+
accepted: 'passed',
|
|
14
|
+
lgtm: 'passed',
|
|
15
|
+
fixed: 'passed',
|
|
16
|
+
resolved: 'passed',
|
|
17
|
+
fail: 'failed',
|
|
18
|
+
rejected: 'failed',
|
|
19
|
+
denied: 'failed',
|
|
20
|
+
not_passed: 'failed',
|
|
21
|
+
err: 'error',
|
|
22
|
+
crash: 'error',
|
|
23
|
+
timeout: 'error',
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
normalizeStatus(status) {
|
|
27
|
+
const lower = status.toLowerCase();
|
|
28
|
+
const canonical = ResultParser.STATUS_ALIASES[lower];
|
|
29
|
+
if (canonical) {
|
|
30
|
+
return canonical;
|
|
31
|
+
}
|
|
32
|
+
return status;
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
parse(output, stageId) {
|
|
36
|
+
const marker = '---RESULT---';
|
|
37
|
+
const startIdx = output.indexOf(marker);
|
|
38
|
+
const endIdx = startIdx !== -1 ? output.indexOf(marker, startIdx + marker.length) : -1;
|
|
39
|
+
|
|
40
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
41
|
+
const resultBlock = output.substring(startIdx + marker.length, endIdx).trim();
|
|
42
|
+
const data = ResultParser.parseResultBlock(resultBlock);
|
|
43
|
+
const normalizedStatus = ResultParser.normalizeStatus(data.status || 'default');
|
|
44
|
+
return {
|
|
45
|
+
status: normalizedStatus,
|
|
46
|
+
data: data.data || {},
|
|
47
|
+
raw: output,
|
|
48
|
+
parsed: true
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return ResultParser.fallbackParse(output, stageId);
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
parseResultBlock(block) {
|
|
56
|
+
const lines = block.split('\n');
|
|
57
|
+
const data = {};
|
|
58
|
+
let status = 'default';
|
|
59
|
+
let currentKey = null;
|
|
60
|
+
let multilineValue = null;
|
|
61
|
+
|
|
62
|
+
const flushMultiline = () => {
|
|
63
|
+
if (currentKey !== null && multilineValue !== null) {
|
|
64
|
+
data[currentKey] = multilineValue.replace(/\n$/, '');
|
|
65
|
+
currentKey = null;
|
|
66
|
+
multilineValue = null;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
for (let i = 0; i < lines.length; i++) {
|
|
71
|
+
const line = lines[i];
|
|
72
|
+
const topLevelMatch = line.match(/^([^:\s][^:]*):\s*(.*)$/);
|
|
73
|
+
|
|
74
|
+
if (topLevelMatch) {
|
|
75
|
+
flushMultiline();
|
|
76
|
+
const key = topLevelMatch[1].trim();
|
|
77
|
+
const value = topLevelMatch[2].trim();
|
|
78
|
+
|
|
79
|
+
if (value !== '') {
|
|
80
|
+
if (key === 'status') {
|
|
81
|
+
status = value;
|
|
82
|
+
} else {
|
|
83
|
+
data[key] = value;
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
currentKey = key;
|
|
87
|
+
multilineValue = '';
|
|
88
|
+
}
|
|
89
|
+
} else if (currentKey !== null && (line.startsWith(' ') || line.startsWith('\t') || line === '')) {
|
|
90
|
+
multilineValue += line + '\n';
|
|
91
|
+
} else if (currentKey !== null) {
|
|
92
|
+
flushMultiline();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
flushMultiline();
|
|
97
|
+
return { status, data };
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
fallbackParse(output, stageId) {
|
|
101
|
+
const lines = output.split('\n');
|
|
102
|
+
let status = 'default';
|
|
103
|
+
const extractedData = {};
|
|
104
|
+
let inResultSection = false;
|
|
105
|
+
|
|
106
|
+
for (const line of lines) {
|
|
107
|
+
const trimmedLine = line.trim();
|
|
108
|
+
const statusMatch = trimmedLine.match(/^(?:status|Status):\s*(\w+)/i);
|
|
109
|
+
if (statusMatch) {
|
|
110
|
+
status = statusMatch[1];
|
|
111
|
+
inResultSection = true;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (inResultSection) {
|
|
116
|
+
const dataMatch = trimmedLine.match(/^(\w+):\s*(.+)$/i);
|
|
117
|
+
if (dataMatch && dataMatch[1].toLowerCase() !== 'status') {
|
|
118
|
+
extractedData[dataMatch[1]] = dataMatch[2];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (status === 'default') {
|
|
124
|
+
const lowerOutput = output.toLowerCase();
|
|
125
|
+
if (lowerOutput.includes('completed') || lowerOutput.includes('success') || lowerOutput.includes('done')) {
|
|
126
|
+
status = 'default';
|
|
127
|
+
extractedData._inferred = 'success_keywords';
|
|
128
|
+
} else if (lowerOutput.includes('error') || lowerOutput.includes('failed')) {
|
|
129
|
+
status = 'error';
|
|
130
|
+
extractedData._inferred = 'error_keywords';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const normalizedStatus = ResultParser.normalizeStatus(status);
|
|
135
|
+
return {
|
|
136
|
+
status: normalizedStatus,
|
|
137
|
+
data: extractedData,
|
|
138
|
+
raw: output,
|
|
139
|
+
parsed: false
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export async function spawnAgent(agentConfig, prompt, options = {}) {
|
|
145
|
+
const {
|
|
146
|
+
timeout = 300,
|
|
147
|
+
logger = null,
|
|
148
|
+
resultParser = ResultParser,
|
|
149
|
+
stageId = 'unknown',
|
|
150
|
+
skillId = null,
|
|
151
|
+
projectRoot = process.cwd(),
|
|
152
|
+
currentChildRef = null
|
|
153
|
+
} = options;
|
|
154
|
+
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
const args = [...agentConfig.args];
|
|
157
|
+
const finalPrompt = prompt;
|
|
158
|
+
|
|
159
|
+
const useShell = process.platform === 'win32' && agentConfig.command !== 'node';
|
|
160
|
+
const useStdin = useShell && finalPrompt.includes('\n');
|
|
161
|
+
|
|
162
|
+
if (!useStdin) {
|
|
163
|
+
args.push(finalPrompt);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (logger) {
|
|
167
|
+
const displayArgs = skillId ? [...args.slice(0, -1), skillId] : args;
|
|
168
|
+
logger.info(`RUN ${agentConfig.command} ${displayArgs.join(' ')}`, stageId);
|
|
169
|
+
const promptLines = prompt.split('\n').filter(l => l.trim());
|
|
170
|
+
if (promptLines.length > 1) {
|
|
171
|
+
for (const line of promptLines.slice(1)) {
|
|
172
|
+
logger.info(` ${line}`, stageId);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const startTime = Date.now();
|
|
178
|
+
|
|
179
|
+
const child = spawn(agentConfig.command, args, {
|
|
180
|
+
cwd: path.resolve(projectRoot, agentConfig.workdir || '.'),
|
|
181
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
182
|
+
shell: useShell
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (currentChildRef) {
|
|
186
|
+
currentChildRef.current = child;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (useStdin) {
|
|
190
|
+
child.stdin.write(finalPrompt);
|
|
191
|
+
child.stdin.end();
|
|
192
|
+
} else {
|
|
193
|
+
child.stdin.end();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let stdout = '';
|
|
197
|
+
let stderr = '';
|
|
198
|
+
let timedOut = false;
|
|
199
|
+
|
|
200
|
+
const timeoutId = setTimeout(() => {
|
|
201
|
+
timedOut = true;
|
|
202
|
+
if (process.platform === 'win32' && child.pid) {
|
|
203
|
+
try { execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: 'pipe' }); } catch {}
|
|
204
|
+
} else {
|
|
205
|
+
child.kill('SIGTERM');
|
|
206
|
+
}
|
|
207
|
+
if (logger) {
|
|
208
|
+
logger.timeout(stageId, timeout);
|
|
209
|
+
}
|
|
210
|
+
reject(new Error(`Stage "${stageId}" timed out after ${timeout}s`));
|
|
211
|
+
}, timeout * 1000);
|
|
212
|
+
|
|
213
|
+
let stdoutBuffer = '';
|
|
214
|
+
let agentText = '';
|
|
215
|
+
child.stdout.on('data', (data) => {
|
|
216
|
+
const chunk = data.toString();
|
|
217
|
+
stdout += chunk;
|
|
218
|
+
stdoutBuffer += chunk;
|
|
219
|
+
const lines = stdoutBuffer.split('\n');
|
|
220
|
+
stdoutBuffer = lines.pop();
|
|
221
|
+
for (const line of lines) {
|
|
222
|
+
if (!line.trim()) continue;
|
|
223
|
+
try {
|
|
224
|
+
const obj = JSON.parse(line);
|
|
225
|
+
if (obj.type === 'content_block_delta' && obj.delta?.text) {
|
|
226
|
+
process.stdout.write(obj.delta.text);
|
|
227
|
+
agentText += obj.delta.text;
|
|
228
|
+
} else if (obj.type === 'assistant' && obj.message?.content) {
|
|
229
|
+
for (const block of obj.message.content) {
|
|
230
|
+
if (block.type === 'text' && block.text) {
|
|
231
|
+
process.stdout.write(block.text);
|
|
232
|
+
agentText += block.text;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
} catch {
|
|
237
|
+
process.stdout.write(line + '\n');
|
|
238
|
+
agentText += line + '\n';
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
child.stderr.on('data', (data) => {
|
|
244
|
+
stderr += data.toString();
|
|
245
|
+
process.stderr.write(data);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
child.on('close', (code) => {
|
|
249
|
+
if (currentChildRef) {
|
|
250
|
+
currentChildRef.current = null;
|
|
251
|
+
}
|
|
252
|
+
clearTimeout(timeoutId);
|
|
253
|
+
const durationMs = Date.now() - startTime;
|
|
254
|
+
|
|
255
|
+
if (stdoutBuffer.trim()) {
|
|
256
|
+
try {
|
|
257
|
+
const obj = JSON.parse(stdoutBuffer);
|
|
258
|
+
if (obj.type === 'content_block_delta' && obj.delta?.text) {
|
|
259
|
+
process.stdout.write(obj.delta.text);
|
|
260
|
+
}
|
|
261
|
+
} catch {
|
|
262
|
+
process.stdout.write(stdoutBuffer + '\n');
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
process.stdout.write('\n');
|
|
266
|
+
|
|
267
|
+
if (timedOut) return;
|
|
268
|
+
|
|
269
|
+
if (logger) {
|
|
270
|
+
logger.cliCall(agentConfig.command, args, code);
|
|
271
|
+
const trimmedOutput = agentText.trim();
|
|
272
|
+
if (trimmedOutput) {
|
|
273
|
+
logger.info(`OUTPUT ↓`, stageId);
|
|
274
|
+
for (const line of trimmedOutput.split('\n')) {
|
|
275
|
+
logger.info(` ${line}`, stageId);
|
|
276
|
+
}
|
|
277
|
+
logger.info(`OUTPUT ↑`, stageId);
|
|
278
|
+
}
|
|
279
|
+
if (stderr.trim()) {
|
|
280
|
+
logger.warn(`STDERR ↓`, stageId);
|
|
281
|
+
for (const line of stderr.trim().split('\n')) {
|
|
282
|
+
logger.warn(` ${line}`, stageId);
|
|
283
|
+
}
|
|
284
|
+
logger.warn(`STDERR ↑`, stageId);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const result = resultParser.parse(stdout, stageId);
|
|
289
|
+
|
|
290
|
+
if (code !== 0 && result.parsed && result.status && result.status !== 'default') {
|
|
291
|
+
if (logger) {
|
|
292
|
+
logger.warn(
|
|
293
|
+
`Agent exited with code ${code}, but RESULT was parsed (status: ${result.status}). Using parsed result.`,
|
|
294
|
+
stageId
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
} else if (code !== 0) {
|
|
298
|
+
const err = new Error(`Agent exited with code ${code}`);
|
|
299
|
+
err.code = 'NON_ZERO_EXIT';
|
|
300
|
+
err.exitCode = code;
|
|
301
|
+
err.stderr = stderr;
|
|
302
|
+
if (logger) {
|
|
303
|
+
logger.error(`Agent exited with code ${code}`, stageId);
|
|
304
|
+
if (stderr.trim()) {
|
|
305
|
+
for (const line of stderr.trim().split('\n')) {
|
|
306
|
+
logger.error(` stderr: ${line}`, stageId);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
reject(err);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
resolve({
|
|
315
|
+
status: result.status || 'default',
|
|
316
|
+
output: stdout,
|
|
317
|
+
stderr: stderr,
|
|
318
|
+
result: result.data || {},
|
|
319
|
+
exitCode: code,
|
|
320
|
+
parsed: result.parsed,
|
|
321
|
+
durationMs
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
child.on('error', (err) => {
|
|
326
|
+
clearTimeout(timeoutId);
|
|
327
|
+
if (!timedOut) {
|
|
328
|
+
if (logger) {
|
|
329
|
+
logger.error(`CLI error: ${err.message}`, stageId);
|
|
330
|
+
}
|
|
331
|
+
reject(err);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export { ResultParser };
|
|
338
|
+
export default { spawnAgent, ResultParser };
|
package/src/runner.mjs
CHANGED
|
@@ -429,10 +429,19 @@ class ResultParser {
|
|
|
429
429
|
parse(output, stageId) {
|
|
430
430
|
const marker = '---RESULT---';
|
|
431
431
|
|
|
432
|
-
// Ищем
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
|
|
432
|
+
// Ищем маркеры ТОЛЬКО на отдельных строках (printResult выводит их на своих строках).
|
|
433
|
+
// Берём последнюю пару: маркер может случайно встретиться в логах/заголовках тикетов
|
|
434
|
+
// до финального блока (напр. "title: ... ---RESULT--- ..."). Regex ^---RESULT---$
|
|
435
|
+
// с multi-line флагом отсеивает такие вхождения.
|
|
436
|
+
const lineMarkerRegex = /^---RESULT---\s*$/gm;
|
|
437
|
+
const markerPositions = [];
|
|
438
|
+
let m;
|
|
439
|
+
while ((m = lineMarkerRegex.exec(output)) !== null) {
|
|
440
|
+
markerPositions.push(m.index);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const endIdx = markerPositions.length >= 2 ? markerPositions[markerPositions.length - 1] : -1;
|
|
444
|
+
const startIdx = markerPositions.length >= 2 ? markerPositions[markerPositions.length - 2] : -1;
|
|
436
445
|
|
|
437
446
|
if (startIdx !== -1 && endIdx !== -1 && startIdx !== endIdx) {
|
|
438
447
|
// Найдены маркеры — парсим структурированный блок
|
|
@@ -922,16 +931,8 @@ class StageExecutor {
|
|
|
922
931
|
};
|
|
923
932
|
}
|
|
924
933
|
|
|
925
|
-
// Курсор = (attempt - 1)
|
|
926
|
-
const cursor = attempt - 1;
|
|
927
|
-
if (cursor >= compatible.length) {
|
|
928
|
-
return {
|
|
929
|
-
blocked: 'attempts_exhausted',
|
|
930
|
-
reason: `Attempt ${attempt} exceeds compatible agents list length (${compatible.length})`,
|
|
931
|
-
attempt,
|
|
932
|
-
triedAgents: compatible
|
|
933
|
-
};
|
|
934
|
-
}
|
|
934
|
+
// Курсор = (attempt - 1) % length — ротация по кругу
|
|
935
|
+
const cursor = (attempt - 1) % compatible.length;
|
|
935
936
|
|
|
936
937
|
const agentId = compatible[cursor];
|
|
937
938
|
// Клонируем stage с подменой instructions (для agents_by_type override)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* get-next-test-id.js - Генератор ID для тест-кейсов
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node get-next-test-id.js --skill coach
|
|
8
|
+
* Output:
|
|
9
|
+
* ---RESULT---
|
|
10
|
+
* next_id: TC-COACH-002
|
|
11
|
+
* ---RESULT---
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from "fs";
|
|
15
|
+
import path from "path";
|
|
16
|
+
import { findProjectRoot } from "workflow-ai/lib/find-root.mjs";
|
|
17
|
+
import { printResult } from "workflow-ai/lib/utils.mjs";
|
|
18
|
+
|
|
19
|
+
const PROJECT_DIR = findProjectRoot();
|
|
20
|
+
|
|
21
|
+
function parseArgs() {
|
|
22
|
+
const args = process.argv.slice(2);
|
|
23
|
+
let skill = null;
|
|
24
|
+
|
|
25
|
+
for (let i = 0; i < args.length; i++) {
|
|
26
|
+
if (args[i] === "--skill" && i + 1 < args.length) {
|
|
27
|
+
skill = args[i + 1];
|
|
28
|
+
i++;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return skill;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function findMaxNumber(skillLower, skillUpper) {
|
|
36
|
+
let maxNum = 0;
|
|
37
|
+
const regex = new RegExp(`^TC-${skillUpper}-(\\d+)\\.yaml$`, "i");
|
|
38
|
+
|
|
39
|
+
const source1 = path.join(PROJECT_DIR, "src", "skills", skillLower, "tests", "cases");
|
|
40
|
+
const source2 = path.join(PROJECT_DIR, ".workflow", "tests", "skills", skillLower, "cases");
|
|
41
|
+
|
|
42
|
+
const dirs = [source1, source2];
|
|
43
|
+
|
|
44
|
+
for (const dir of dirs) {
|
|
45
|
+
if (!fs.existsSync(dir)) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
50
|
+
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
if (entry.isFile()) {
|
|
53
|
+
const match = entry.name.match(regex);
|
|
54
|
+
if (match) {
|
|
55
|
+
const num = parseInt(match[1], 10);
|
|
56
|
+
if (num > maxNum) {
|
|
57
|
+
maxNum = num;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return maxNum;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function formatNumber(num) {
|
|
68
|
+
return num.toString().padStart(3, "0");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function main() {
|
|
72
|
+
const skill = parseArgs();
|
|
73
|
+
|
|
74
|
+
if (!skill) {
|
|
75
|
+
console.error("Usage:");
|
|
76
|
+
console.error(" node get-next-test-id.js --skill <name>");
|
|
77
|
+
printResult({
|
|
78
|
+
status: "error",
|
|
79
|
+
error: "Missing required argument: --skill <name>",
|
|
80
|
+
});
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const skillLower = skill.toLowerCase();
|
|
85
|
+
const skillUpper = skill.toUpperCase().replace(/-/g, "-");
|
|
86
|
+
|
|
87
|
+
const maxNum = findMaxNumber(skillLower, skillUpper);
|
|
88
|
+
const nextNum = maxNum + 1;
|
|
89
|
+
const nextId = `TC-${skillUpper}-${formatNumber(nextNum)}`;
|
|
90
|
+
|
|
91
|
+
printResult({ status: "success", next_id: nextId });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
main();
|