workflow-ai 1.0.25 → 1.0.26

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workflow-ai",
3
- "version": "1.0.25",
3
+ "version": "1.0.26",
4
4
  "description": "AI Agent Workflow Coordinator — kanban-based pipeline for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,73 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { findProjectRoot } from './find-root.mjs';
4
+
5
+ const LEVELS = {
6
+ DEBUG: -1,
7
+ INFO: 0,
8
+ WARN: 1,
9
+ ERROR: 2
10
+ };
11
+
12
+ const COLORS = {
13
+ DEBUG: '\x1b[90m',
14
+ INFO: '\x1b[36m',
15
+ WARN: '\x1b[33m',
16
+ ERROR: '\x1b[31m',
17
+ RESET: '\x1b[0m'
18
+ };
19
+
20
+ function formatTimestamp() {
21
+ const now = new Date();
22
+ return now.toISOString().replace('T', ' ').substring(0, 19);
23
+ }
24
+
25
+ function ensureLogDir(logFilePath) {
26
+ const logDir = path.dirname(logFilePath);
27
+ if (!fs.existsSync(logDir)) {
28
+ fs.mkdirSync(logDir, { recursive: true });
29
+ }
30
+ }
31
+
32
+ function createLogger(logFilePath = null, consoleLevel = LEVELS.INFO) {
33
+ const projectRoot = findProjectRoot();
34
+ const defaultLogPath = path.join(projectRoot, '.workflow/logs/pipeline.log');
35
+ const logPath = logFilePath || defaultLogPath;
36
+
37
+ ensureLogDir(logPath);
38
+
39
+ function writeToFile(formatted) {
40
+ fs.appendFileSync(logPath, formatted + '\n', 'utf8');
41
+ }
42
+
43
+ function writeToConsole(formatted, level) {
44
+ if (LEVELS[level] < consoleLevel) return;
45
+
46
+ const color = COLORS[level];
47
+ const reset = COLORS.RESET;
48
+
49
+ if (level === 'ERROR') {
50
+ console.error(`${color}${formatted}${reset}`);
51
+ } else if (level === 'WARN') {
52
+ console.warn(`${color}${formatted}${reset}`);
53
+ } else {
54
+ console.log(`${color}${formatted}${reset}`);
55
+ }
56
+ }
57
+
58
+ function log(level, message) {
59
+ const timestamp = formatTimestamp();
60
+ const formatted = `[${timestamp}] [${level}] ${message}`;
61
+ writeToFile(formatted);
62
+ writeToConsole(formatted, level);
63
+ }
64
+
65
+ return {
66
+ debug: (message) => log('DEBUG', message),
67
+ info: (message) => log('INFO', message),
68
+ warn: (message) => log('WARN', message),
69
+ error: (message) => log('ERROR', message)
70
+ };
71
+ }
72
+
73
+ export { createLogger, LEVELS };
package/src/lib/utils.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import YAML from 'js-yaml';
2
2
  import { fileURLToPath } from 'url';
3
3
  import path from 'path';
4
+ import fs from 'fs';
4
5
 
5
6
  /**
6
7
  * Парсит YAML frontmatter из markdown-файла.
@@ -116,17 +117,26 @@ export function getPackageRoot() {
116
117
  export function getLastReviewStatus(content) {
117
118
  if (!content) return null;
118
119
 
119
- // Находим секцию "## Ревью" захватываем всё до следующего заголовка ## или конца файла
120
- const headerIdx = content.search(/^##\s*Ревью\s*$/m);
121
- if (headerIdx === -1) return null;
120
+ // Находим последний заголовок H2 "## Ревью" (только строки начинающиеся с "## ")
121
+ const lines = content.split('\n');
122
+ let lastHeaderLineIndex = -1;
123
+
124
+ for (let i = 0; i < lines.length; i++) {
125
+ if (lines[i].startsWith('## ') && lines[i].includes('Ревью')) {
126
+ lastHeaderLineIndex = i;
127
+ }
128
+ }
122
129
 
123
- const bodyStart = content.indexOf('\n', headerIdx);
124
- if (bodyStart === -1) return null;
130
+ if (lastHeaderLineIndex === -1) return null;
125
131
 
126
- const nextH2 = content.indexOf('\n## ', bodyStart);
127
- const reviewSection = (nextH2 === -1
128
- ? content.slice(bodyStart + 1)
129
- : content.slice(bodyStart + 1, nextH2)).trim();
132
+ // Собираем содержимое после заголовка до следующего H2 заголовка
133
+ const reviewLines = [];
134
+ for (let i = lastHeaderLineIndex + 1; i < lines.length; i++) {
135
+ if (lines[i].startsWith('## ')) break; // следующий H2 заголовок
136
+ reviewLines.push(lines[i]);
137
+ }
138
+
139
+ const reviewSection = reviewLines.join('\n').trim();
130
140
  if (!reviewSection) return null;
131
141
 
132
142
  // Пробуем распарсить табличный формат
@@ -154,9 +164,96 @@ export function getLastReviewStatus(content) {
154
164
  if (listItems.length > 0) {
155
165
  // Последний элемент списка = самое свежее ревью (записи ведутся хронологически)
156
166
  const latestItem = listItems[listItems.length - 1].trim();
157
- const statusMatch = latestItem.match(/:\s*(passed|failed)\b/i);
167
+ const statusMatch = latestItem.match(/:\s*(passed|failed|skipped)\b/i);
158
168
  if (statusMatch) return statusMatch[1].toLowerCase();
159
169
  }
160
170
 
161
171
  return null;
162
172
  }
173
+
174
+ /**
175
+ * Загружает конфигурацию правил перемещения тикетов.
176
+ *
177
+ * @param {string} configPath - Путь к конфигурационному файлу
178
+ * @returns {object} Объект конфигурации с правилами
179
+ */
180
+ export function loadTicketMovementRules(configPath) {
181
+ if (!fs.existsSync(configPath)) {
182
+ throw new Error(`Config file not found: ${configPath}`);
183
+ }
184
+ const content = fs.readFileSync(configPath, 'utf8');
185
+ return YAML.load(content);
186
+ }
187
+
188
+ /**
189
+ * Проверяет все тикеты плана и закрывает его если все выполнены.
190
+ *
191
+ * @param {string} workflowDir - Путь к директории .workflow/
192
+ * @param {string} planId - Нормализованный ID плана (например "PLAN-002")
193
+ * @returns {{ closed: boolean, reason: string, total: number, done: number }}
194
+ */
195
+ export function checkAndClosePlan(workflowDir, planId) {
196
+ if (!workflowDir || !planId) {
197
+ return { closed: false, reason: 'Missing workflowDir or planId', total: 0, done: 0 };
198
+ }
199
+
200
+ const ticketsDir = path.join(workflowDir, 'tickets');
201
+ const allDirNames = ['backlog', 'ready', 'in-progress', 'blocked', 'review', 'done'];
202
+ const allTickets = [];
203
+
204
+ for (const dirName of allDirNames) {
205
+ const dir = path.join(ticketsDir, dirName);
206
+ if (!fs.existsSync(dir)) continue;
207
+
208
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.md') && f !== '.gitkeep.md');
209
+ for (const file of files) {
210
+ try {
211
+ const content = fs.readFileSync(path.join(dir, file), 'utf8');
212
+ const { frontmatter } = parseFrontmatter(content);
213
+ if (normalizePlanId(frontmatter.parent_plan) === planId) {
214
+ allTickets.push({ id: frontmatter.id || file.replace('.md', ''), dir: dirName });
215
+ }
216
+ } catch (_) { /* skip malformed */ }
217
+ }
218
+ }
219
+
220
+ const total = allTickets.length;
221
+ const done = allTickets.filter(t => t.dir === 'done').length;
222
+
223
+ if (total === 0) {
224
+ return { closed: false, reason: 'No tickets found for plan', total, done };
225
+ }
226
+
227
+ if (done < total) {
228
+ return { closed: false, reason: `${done}/${total} tickets done`, total, done };
229
+ }
230
+
231
+ const plansDir = path.join(workflowDir, 'plans', 'current');
232
+ if (!fs.existsSync(plansDir)) {
233
+ return { closed: false, reason: 'Plans directory not found', total, done };
234
+ }
235
+
236
+ const planFile = fs.readdirSync(plansDir)
237
+ .filter(f => f.endsWith('.md'))
238
+ .find(f => normalizePlanId(f) === planId);
239
+
240
+ if (!planFile) {
241
+ return { closed: false, reason: 'Plan file not found', total, done };
242
+ }
243
+
244
+ const planPath = path.join(plansDir, planFile);
245
+ const planContent = fs.readFileSync(planPath, 'utf8');
246
+ const { frontmatter, body } = parseFrontmatter(planContent);
247
+
248
+ if (frontmatter.status === 'completed') {
249
+ return { closed: false, reason: 'Plan already completed', total, done };
250
+ }
251
+
252
+ frontmatter.status = 'completed';
253
+ frontmatter.completed_at = new Date().toISOString();
254
+ frontmatter.updated_at = new Date().toISOString();
255
+
256
+ fs.writeFileSync(planPath, serializeFrontmatter(frontmatter) + body, 'utf8');
257
+
258
+ return { closed: true, reason: 'All tickets done', total, done };
259
+ }
package/src/runner.mjs CHANGED
@@ -8,16 +8,18 @@ import yaml from 'js-yaml';
8
8
  import { findProjectRoot } from './lib/find-root.mjs';
9
9
 
10
10
  // ============================================================================
11
- // Logger — система логирования с уровнями INFO/WARN/ERROR
11
+ // Logger — система логирования с уровнями DEBUG/INFO/WARN/ERROR
12
12
  // ============================================================================
13
13
  class Logger {
14
14
  static LEVELS = {
15
+ DEBUG: -1,
15
16
  INFO: 0,
16
17
  WARN: 1,
17
18
  ERROR: 2
18
19
  };
19
20
 
20
21
  static COLORS = {
22
+ DEBUG: '\x1b[90m', // gray
21
23
  INFO: '\x1b[36m', // cyan
22
24
  WARN: '\x1b[33m', // yellow
23
25
  ERROR: '\x1b[31m', // red
@@ -28,6 +30,7 @@ class Logger {
28
30
  this.logFilePath = logFilePath;
29
31
  this.consoleLevel = consoleLevel;
30
32
  this.stats = {
33
+ debug: 0,
31
34
  info: 0,
32
35
  warn: 0,
33
36
  error: 0,
@@ -53,17 +56,6 @@ class Logger {
53
56
  }
54
57
  }
55
58
 
56
- /**
57
- * Создаёт директорию для логов если она не существует
58
- */
59
- _ensureLogDirectory() {
60
- const logDir = path.dirname(this.logFilePath);
61
- if (!fs.existsSync(logDir)) {
62
- fs.mkdirSync(logDir, { recursive: true });
63
- console.log(`[Logger] Created log directory: ${logDir}`);
64
- }
65
- }
66
-
67
59
  /**
68
60
  * Открывает файл для записи (append mode)
69
61
  */
@@ -142,7 +134,8 @@ class Logger {
142
134
  this._writeToConsole(formattedMessage, level);
143
135
 
144
136
  // Обновляем статистику
145
- if (level === 'INFO') this.stats.info++;
137
+ if (level === 'DEBUG') this.stats.debug++;
138
+ else if (level === 'INFO') this.stats.info++;
146
139
  else if (level === 'WARN') this.stats.warn++;
147
140
  else if (level === 'ERROR') this.stats.error++;
148
141
  }
@@ -168,6 +161,13 @@ class Logger {
168
161
  this._log('ERROR', stage, message);
169
162
  }
170
163
 
164
+ /**
165
+ * Логгирует DEBUG сообщение
166
+ */
167
+ debug(message, stage) {
168
+ this._log('DEBUG', stage, message);
169
+ }
170
+
171
171
  /**
172
172
  * Логгирует старт stage
173
173
  */
@@ -242,9 +242,10 @@ class Logger {
242
242
  '┌─────────────────────────────────────────────────────────┐',
243
243
  '│ LOG STATISTICS │',
244
244
  '├─────────────────────────────────────────────────────────┤',
245
- `│ INFO messages: ${String(this.stats.info).padEnd(34)}│`,
246
- `│ WARN messages: ${String(this.stats.warn).padEnd(34)}│`,
247
- `│ ERROR messages: ${String(this.stats.error).padEnd(34)}│`,
245
+ `│ DEBUG messages: ${String(this.stats.debug).padEnd(34)}│`,
246
+ `│ INFO messages: ${String(this.stats.info).padEnd(34)}│`,
247
+ `│ WARN messages: ${String(this.stats.warn).padEnd(34)}│`,
248
+ `│ ERROR messages: ${String(this.stats.error).padEnd(34)}│`,
248
249
  '├─────────────────────────────────────────────────────────┤',
249
250
  '│ STAGE STATISTICS │',
250
251
  '├─────────────────────────────────────────────────────────┤',
@@ -21,7 +21,10 @@
21
21
  import fs from 'fs';
22
22
  import path from 'path';
23
23
  import { findProjectRoot } from '../lib/find-root.mjs';
24
- import { parseFrontmatter, printResult, normalizePlanId, extractPlanId, getLastReviewStatus, serializeFrontmatter } from '../lib/utils.mjs';
24
+ import { parseFrontmatter, printResult, normalizePlanId, extractPlanId, getLastReviewStatus, serializeFrontmatter, loadTicketMovementRules, checkAndClosePlan } from '../lib/utils.mjs';
25
+ import { createLogger } from '../lib/logger.mjs';
26
+
27
+ const logger = createLogger();
25
28
 
26
29
  // Корень проекта
27
30
  const PROJECT_DIR = findProjectRoot();
@@ -72,12 +75,133 @@ function checkCondition(condition) {
72
75
  return false;
73
76
 
74
77
  default:
75
- // Неизвестный тип условия - считаем выполненным
76
- console.error(`[WARN] Unknown condition type: ${type}`);
78
+ logger.warn(`Unknown condition type: ${type}`);
77
79
  return true;
78
80
  }
79
81
  }
80
82
 
83
+ /**
84
+ * Парсит секцию "## Ревью" тикета и возвращает все записи ревью.
85
+ * @param {string} content - Содержимое тикета
86
+ * @returns {Array<{date: string, status: string, comment: string}>}
87
+ */
88
+ function parseReviewSection(content) {
89
+ if (!content) return [];
90
+
91
+ const headerIdx = content.search(/^##\s*Ревью\s*$/m);
92
+ if (headerIdx === -1) return [];
93
+
94
+ const bodyStart = content.indexOf('\n', headerIdx);
95
+ if (bodyStart === -1) return [];
96
+
97
+ const nextH2 = content.indexOf('\n## ', bodyStart);
98
+ const reviewSection = (nextH2 === -1
99
+ ? content.slice(bodyStart + 1)
100
+ : content.slice(bodyStart + 1, nextH2)).trim();
101
+
102
+ const reviews = [];
103
+
104
+ const tableRows = reviewSection.split('\n').filter(line => line.trim().startsWith('|'));
105
+ if (tableRows.length >= 2) {
106
+ const dataRows = tableRows.slice(2).filter(row => {
107
+ const cells = row.split('|').map(c => c.trim()).filter(c => c);
108
+ return cells.length >= 2;
109
+ });
110
+
111
+ for (const row of dataRows) {
112
+ const cells = row.split('|').map(c => c.trim()).filter(c => c);
113
+ const date = cells[0] || '';
114
+ const statusRaw = cells[1]?.toLowerCase() || '';
115
+ const comment = cells[2] || '';
116
+ let status = null;
117
+ if (statusRaw.includes('passed')) status = 'passed';
118
+ else if (statusRaw.includes('failed')) status = 'failed';
119
+ else if (statusRaw.includes('skipped')) status = 'skipped';
120
+
121
+ if (status) {
122
+ reviews.push({ date, status, comment });
123
+ }
124
+ }
125
+ }
126
+
127
+ const listItems = reviewSection.split('\n').filter(line => line.trim().match(/^[-*]\s/));
128
+ for (const item of listItems) {
129
+ const trimmed = item.trim();
130
+ const dateMatch = trimmed.match(/^[-*]\s*(\d{4}-\d{2}-\d{2})/);
131
+ const statusMatch = trimmed.match(/:\s*(passed|failed|skipped)\b/i);
132
+ if (dateMatch && statusMatch) {
133
+ reviews.push({
134
+ date: dateMatch[1],
135
+ status: statusMatch[1].toLowerCase(),
136
+ comment: trimmed.replace(/^[-*]\s*\d{4}-\d{2}-\d{2}:\s*(passed|failed|skipped)\b/i, '').trim()
137
+ });
138
+ }
139
+ }
140
+
141
+ return reviews;
142
+ }
143
+
144
+ /**
145
+ * Вычисляет метрики ревью-итераций для всех тикетов
146
+ * @returns {object} Метрики: iterations, avgTimeToFirstPassed, failedVsPassed
147
+ */
148
+ function calculateReviewMetrics() {
149
+ const allDirs = [BACKLOG_DIR, READY_DIR, IN_PROGRESS_DIR, BLOCKED_DIR, REVIEW_DIR, DONE_DIR];
150
+ const ticketMetrics = {};
151
+ let totalFailed = 0;
152
+ let totalPassed = 0;
153
+ let firstPassedTimes = [];
154
+
155
+ for (const dir of allDirs) {
156
+ if (!fs.existsSync(dir)) continue;
157
+
158
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.md') && f !== '.gitkeep.md');
159
+
160
+ for (const file of files) {
161
+ const filePath = path.join(dir, file);
162
+ try {
163
+ const content = fs.readFileSync(filePath, 'utf8');
164
+ const { frontmatter } = parseFrontmatter(content);
165
+ const ticketId = frontmatter.id || file.replace('.md', '');
166
+
167
+ const reviews = parseReviewSection(content);
168
+ if (reviews.length === 0) continue;
169
+
170
+ ticketMetrics[ticketId] = reviews.length;
171
+
172
+ for (const review of reviews) {
173
+ if (review.status === 'failed') totalFailed++;
174
+ else if (review.status === 'passed') totalPassed++;
175
+ }
176
+
177
+ const firstPassed = reviews.find(r => r.status === 'passed');
178
+ if (firstPassed && firstPassed.date) {
179
+ const ticketCreated = new Date(frontmatter.created_at || '1970-01-01');
180
+ const passedDate = new Date(firstPassed.date);
181
+ const daysToPass = Math.floor((passedDate - ticketCreated) / (1000 * 60 * 60 * 24));
182
+ if (daysToPass >= 0) {
183
+ firstPassedTimes.push(daysToPass);
184
+ }
185
+ }
186
+ } catch (e) {
187
+ // Skip errors
188
+ }
189
+ }
190
+ }
191
+
192
+ const avgTimeToFirstPassed = firstPassedTimes.length > 0
193
+ ? Math.round(firstPassedTimes.reduce((a, b) => a + b, 0) / firstPassedTimes.length)
194
+ : null;
195
+
196
+ return {
197
+ iterations_per_ticket: ticketMetrics,
198
+ total_failed: totalFailed,
199
+ total_passed: totalPassed,
200
+ avg_time_to_first_passed_days: avgTimeToFirstPassed,
201
+ tickets_with_reviews: Object.keys(ticketMetrics).length
202
+ };
203
+ }
204
+
81
205
  /**
82
206
  * Проверяет зависимости тикета
83
207
  */
@@ -94,19 +218,23 @@ function checkDependencies(dependencies) {
94
218
 
95
219
  /**
96
220
  * Авто-коррекция тикетов на основе статуса ревью.
97
- * Сканирует все директории и перемещает тикеты по правилам:
98
- * - blocked → done (если review = passed)
99
- * - blocked → backlog (если review = failed, НЕ при null)
100
- * - done → backlog (если review = failed)
101
- * - review → done (если review = passed)
102
- * - in-progress → done (если review = passed)
103
- * - ready → done (если review = passed)
221
+ * Сканирует все директории и перемещает тикеты по правилам из конфига.
104
222
  *
223
+ * @param {object} config - Конфигурация правил перемещения
105
224
  * @returns {object} Результат: { moved: Array<{id, from, to, reason}> }
106
225
  */
107
- function autoCorrectTickets() {
226
+ function autoCorrectTickets(config) {
108
227
  const moved = [];
109
228
 
229
+ const dirMap = {
230
+ backlog: BACKLOG_DIR,
231
+ ready: READY_DIR,
232
+ in_progress: IN_PROGRESS_DIR,
233
+ blocked: BLOCKED_DIR,
234
+ review: REVIEW_DIR,
235
+ done: DONE_DIR
236
+ };
237
+
110
238
  /**
111
239
  * Перемещает тикет из одной директории в другую
112
240
  */
@@ -119,23 +247,18 @@ function autoCorrectTickets() {
119
247
  }
120
248
 
121
249
  try {
122
- // Читаем содержимое
123
250
  const content = fs.readFileSync(fromPath, 'utf8');
124
251
  const { frontmatter, body } = parseFrontmatter(content);
125
252
 
126
- // Обновляем updated_at
127
253
  frontmatter.updated_at = new Date().toISOString();
128
254
 
129
- // Если перемещаем в done — ставим completed_at
130
255
  if (toDir === DONE_DIR && !frontmatter.completed_at) {
131
256
  frontmatter.completed_at = new Date().toISOString();
132
257
  }
133
258
 
134
- // Сериализуем и записываем в новую директорию
135
259
  const newContent = serializeFrontmatter(frontmatter) + body;
136
260
  fs.writeFileSync(toPath, newContent, 'utf8');
137
261
 
138
- // Удаляем старый файл
139
262
  fs.unlinkSync(fromPath);
140
263
 
141
264
  console.log(`[AUTO-CORRECT] ${ticketId}: ${path.basename(fromDir)} → ${path.basename(toDir)} (${reason})`);
@@ -149,7 +272,7 @@ function autoCorrectTickets() {
149
272
 
150
273
  return true;
151
274
  } catch (e) {
152
- console.error(`[ERROR] Failed to move ticket ${ticketId}: ${e.message}`);
275
+ logger.error(`Failed to move ticket ${ticketId}: ${e.message}`);
153
276
  return false;
154
277
  }
155
278
  }
@@ -157,7 +280,7 @@ function autoCorrectTickets() {
157
280
  /**
158
281
  * Обрабатывает тикеты в указанной директории
159
282
  */
160
- function processDirectory(dir, rules) {
283
+ function processDirectory(dir, rules, dirName) {
161
284
  if (!fs.existsSync(dir)) return;
162
285
 
163
286
  const files = fs.readdirSync(dir)
@@ -170,112 +293,46 @@ function autoCorrectTickets() {
170
293
  const { frontmatter } = parseFrontmatter(content);
171
294
  const ticketId = frontmatter.id || file.replace('.md', '');
172
295
 
173
- // Получаем статус ревью
174
296
  const reviewStatus = getLastReviewStatus(content);
175
297
 
176
- // Применяем правила
177
298
  for (const rule of rules) {
178
- if (rule.condition(reviewStatus)) {
179
- moveTicket(ticketId, dir, rule.toDir, rule.reason);
180
- break; // Только одно перемещение на тикет
299
+ const ruleCondition = rule.condition;
300
+ let shouldMove = false;
301
+
302
+ if (ruleCondition === null) {
303
+ shouldMove = reviewStatus === null;
304
+ } else {
305
+ shouldMove = reviewStatus === ruleCondition;
306
+ }
307
+
308
+ if (shouldMove) {
309
+ const targetDirName = rule.to_dir;
310
+ const targetDir = dirMap[targetDirName];
311
+ if (targetDir) {
312
+ moveTicket(ticketId, dir, targetDir, rule.reason);
313
+ }
314
+ break;
181
315
  }
182
316
  }
183
317
  } catch (e) {
184
- console.error(`[WARN] Failed to process ticket ${file}: ${e.message}`);
318
+ logger.warn(`Failed to process ticket ${file}: ${e.message}`);
185
319
  }
186
320
  }
187
321
  }
188
322
 
189
- // Правила для backlog/ (защита от ошибочного перемещения завершённых тикетов)
190
- processDirectory(BACKLOG_DIR, [
191
- {
192
- condition: (status) => status === 'passed',
193
- toDir: DONE_DIR,
194
- reason: 'review passed'
195
- },
196
- {
197
- condition: (status) => status === 'skipped',
198
- toDir: DONE_DIR,
199
- reason: 'review skipped'
200
- }
201
- ]);
202
-
203
- // Правила для blocked/
204
- processDirectory(BLOCKED_DIR, [
205
- {
206
- condition: (status) => status === 'passed',
207
- toDir: DONE_DIR,
208
- reason: 'review passed'
209
- },
210
- {
211
- condition: (status) => status === 'skipped',
212
- toDir: DONE_DIR,
213
- reason: 'review skipped'
214
- },
215
- {
216
- condition: (status) => status === 'failed',
217
- toDir: BACKLOG_DIR,
218
- reason: 'review failed'
219
- }
220
- // null (нет ревью) — не перемещаем
221
- ]);
222
-
223
- // Правила для done/
224
- processDirectory(DONE_DIR, [
225
- {
226
- condition: (status) => status === 'failed',
227
- toDir: BACKLOG_DIR,
228
- reason: 'review failed'
229
- },
230
- {
231
- condition: (status) => status === null,
232
- toDir: BACKLOG_DIR,
233
- reason: 'no review'
234
- }
235
- // passed или skipped — не перемещаем
236
- ]);
237
-
238
- // Правила для review/
239
- processDirectory(REVIEW_DIR, [
240
- {
241
- condition: (status) => status === 'passed',
242
- toDir: DONE_DIR,
243
- reason: 'review passed'
244
- },
245
- {
246
- condition: (status) => status === 'skipped',
247
- toDir: DONE_DIR,
248
- reason: 'review skipped'
249
- }
250
- ]);
251
-
252
- // Правила для in-progress/
253
- processDirectory(IN_PROGRESS_DIR, [
254
- {
255
- condition: (status) => status === 'passed',
256
- toDir: DONE_DIR,
257
- reason: 'review passed'
258
- },
259
- {
260
- condition: (status) => status === 'skipped',
261
- toDir: DONE_DIR,
262
- reason: 'review skipped'
263
- }
264
- ]);
265
-
266
- // Правила для ready/
267
- processDirectory(READY_DIR, [
268
- {
269
- condition: (status) => status === 'passed',
270
- toDir: DONE_DIR,
271
- reason: 'review passed'
272
- },
273
- {
274
- condition: (status) => status === 'skipped',
275
- toDir: DONE_DIR,
276
- reason: 'review skipped'
323
+ if (!config || !config.rules) {
324
+ logger.error('Ticket movement rules config not loaded');
325
+ return { moved };
326
+ }
327
+
328
+ const rulesConfig = config.rules;
329
+
330
+ for (const [dirName, rules] of Object.entries(rulesConfig)) {
331
+ const dir = dirMap[dirName];
332
+ if (dir) {
333
+ processDirectory(dir, rules, dirName);
277
334
  }
278
- ]);
335
+ }
279
336
 
280
337
  return { moved };
281
338
  }
@@ -438,7 +495,7 @@ function pickNextTicket(planId) {
438
495
  const completedInProgress = filterByPlan(findCompletedInProgress(), planId);
439
496
  if (completedInProgress.length > 0) {
440
497
  const first = completedInProgress[0];
441
- console.log(`[INFO] Found completed ticket in in-progress/: ${first.id}`);
498
+ logger.info(`Found completed ticket in in-progress/: ${first.id}`);
442
499
  return {
443
500
  status: 'completed_in_progress',
444
501
  ticket_id: first.id
@@ -517,32 +574,68 @@ async function main() {
517
574
  const planId = extractPlanId();
518
575
 
519
576
  if (planId) {
520
- console.log(`[INFO] Filtering by plan_id: ${planId}`);
577
+ logger.info(`Filtering by plan_id: ${planId}`);
578
+ }
579
+
580
+ const configPath = path.join(WORKFLOW_DIR, 'config', 'ticket-movement-rules.yaml');
581
+ let movementConfig = null;
582
+ try {
583
+ movementConfig = loadTicketMovementRules(configPath);
584
+ logger.info('Loaded ticket movement rules from config');
585
+ } catch (e) {
586
+ logger.warn(`Failed to load ticket movement config: ${e.message}`);
521
587
  }
522
588
 
523
- // Авто-коррекция тикетов перед выбором задачи
524
- console.log('[INFO] Running auto-correction...');
525
- const correctionResult = autoCorrectTickets();
589
+ logger.info('Running auto-correction...');
590
+ const correctionResult = autoCorrectTickets(movementConfig);
526
591
  if (correctionResult.moved.length > 0) {
527
- console.log(`[INFO] Auto-corrected ${correctionResult.moved.length} ticket(s)`);
592
+ logger.info(`Auto-corrected ${correctionResult.moved.length} ticket(s)`);
528
593
  }
529
594
 
530
- console.log(`[INFO] Scanning ready/ directory: ${READY_DIR}`);
595
+ if (planId) {
596
+ const closeResult = checkAndClosePlan(WORKFLOW_DIR, planId);
597
+ if (closeResult.closed) {
598
+ logger.info(`Plan ${planId} closed: all ${closeResult.total} tickets done`);
599
+ } else if (closeResult.total > 0) {
600
+ logger.info(`Plan ${planId} progress: ${closeResult.done}/${closeResult.total} tickets done`);
601
+ }
602
+ }
603
+
604
+ logger.info(`Scanning ready/ directory: ${READY_DIR}`);
531
605
 
532
606
  const result = pickNextTicket(planId);
533
607
 
534
608
  if (result.status === 'found') {
535
- console.log(`[INFO] Selected ticket: ${result.ticket_id} (${result.title})`);
536
- console.log(`[INFO] Priority: ${result.priority}, Type: ${result.type}`);
609
+ logger.info(`Selected ticket: ${result.ticket_id} (${result.title})`);
610
+ logger.info(`Priority: ${result.priority}, Type: ${result.type}`);
537
611
  } else {
538
- console.log(`[INFO] ${result.reason}`);
612
+ logger.info(result.reason);
539
613
  }
540
614
 
541
- // Добавляем информацию о авто-коррекции в результат
615
+ logger.info('Calculating review metrics...');
616
+ const reviewMetrics = calculateReviewMetrics();
617
+ logger.info(`Found ${reviewMetrics.tickets_with_reviews} tickets with reviews`);
618
+ logger.info(`Total failed: ${reviewMetrics.total_failed}, passed: ${reviewMetrics.total_passed}`);
619
+
620
+ const metricsDir = path.join(WORKFLOW_DIR, 'metrics');
621
+ if (!fs.existsSync(metricsDir)) {
622
+ fs.mkdirSync(metricsDir, { recursive: true });
623
+ }
624
+ const metricsFile = path.join(metricsDir, 'review-metrics.json');
625
+ fs.writeFileSync(metricsFile, JSON.stringify(reviewMetrics, null, 2), 'utf8');
626
+ logger.info(`Metrics saved to ${metricsFile}`);
627
+
542
628
  const finalResult = {
543
629
  ...result,
544
630
  auto_corrected: correctionResult.moved.length,
545
- moved_tickets: correctionResult.moved.map(m => m.id).join(',')
631
+ moved_tickets: correctionResult.moved.map(m => m.id).join(','),
632
+ review_metrics: {
633
+ tickets_with_reviews: reviewMetrics.tickets_with_reviews,
634
+ total_failed: reviewMetrics.total_failed,
635
+ total_passed: reviewMetrics.total_passed,
636
+ avg_time_to_first_passed_days: reviewMetrics.avg_time_to_first_passed_days,
637
+ iterations_per_ticket: reviewMetrics.iterations_per_ticket
638
+ }
546
639
  };
547
640
 
548
641
  printResult(finalResult);
@@ -553,7 +646,7 @@ async function main() {
553
646
  }
554
647
 
555
648
  main().catch(e => {
556
- console.error(`[ERROR] ${e.message}`);
649
+ logger.error(e.message);
557
650
  printResult({ status: 'error', error: e.message });
558
651
  process.exit(1);
559
652
  });
@@ -63,12 +63,7 @@ description: Декомпозировать недочёты (gaps) из ана
63
63
 
64
64
  ### 5. Определить тип каждого тикета
65
65
 
66
- | Тип | Префикс | Когда использовать |
67
- |-----|---------|-------------------|
68
- | Исправление | FIX | Результат не соответствует ожиданиям |
69
- | Реализация | IMPL | Задача была пропущена |
70
- | Ревью | REVIEW | Нужна дополнительная проверка |
71
- | Документация | DOCS | Не хватает документации |
66
+ Прочитай актуальные типы задач из `.workflow/config/config.yaml` (секция `task_types`). Используй **только** префиксы, определённые в конфиге. Не используй типы, которых нет в конфиге.
72
67
 
73
68
  ### 6. Назначить приоритеты
74
69
 
@@ -39,14 +39,7 @@ description: Декомпозировать план на исполняемые
39
39
 
40
40
  ### 3. Определить тип каждого тикета
41
41
 
42
- | Тип | Префикс | Описание |
43
- |-----|---------|----------|
44
- | Архитектура | ARCH | Создание планов, архитектуры |
45
- | Реализация | IMPL | Написание кода |
46
- | Исправление | FIX | Багфиксы |
47
- | Ревью | REVIEW | Проверка кода/документации |
48
- | Документация | DOCS | Написание документации |
49
- | Администрирование | ADMIN | Настройка, конфигурация |
42
+ Прочитай актуальные типы задач из `.workflow/config/config.yaml` (секция `task_types`). Используй **только** префиксы, определённые в конфиге. Не используй типы, которых нет в конфиге.
50
43
 
51
44
  ### 4. Определить зависимости
52
45