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 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.62",
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 (15 directories)
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 (15 directories)');
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
- // Ищем ПОСЛЕДНЮЮ пару маркеров: printResult всегда печатается в конце скрипта,
433
- // а маркер может случайно встретиться в логах до него (напр. в заголовке тикета).
434
- const endIdx = output.lastIndexOf(marker);
435
- const startIdx = endIdx !== -1 ? output.lastIndexOf(marker, endIdx - 1) : -1;
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), clamped
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();