workflow-ai 1.0.25 → 1.0.27

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.
@@ -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
- import YAML from 'js-yaml';
1
+ import YAML from './js-yaml.mjs';
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
@@ -4,20 +4,22 @@ import fs from 'fs';
4
4
  import path from 'path';
5
5
  import { spawn, execSync } from 'child_process';
6
6
  import crypto from 'crypto';
7
- import yaml from 'js-yaml';
7
+ import yaml from './lib/js-yaml.mjs';
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
  '├─────────────────────────────────────────────────────────┤',
@@ -20,7 +20,7 @@
20
20
 
21
21
  import fs from 'fs';
22
22
  import path from 'path';
23
- import YAML from 'js-yaml';
23
+ import YAML from '../lib/js-yaml.mjs';
24
24
  import { findProjectRoot } from '../lib/find-root.mjs';
25
25
  import { parseFrontmatter, printResult } from '../lib/utils.mjs';
26
26
 
@@ -12,7 +12,7 @@
12
12
 
13
13
  import fs from 'fs';
14
14
  import path from 'path';
15
- import YAML from 'js-yaml';
15
+ import YAML from '../lib/js-yaml.mjs';
16
16
  import { findProjectRoot } from '../lib/find-root.mjs';
17
17
  import { parseFrontmatter, printResult, serializeFrontmatter } from '../lib/utils.mjs';
18
18
 
@@ -17,7 +17,7 @@
17
17
 
18
18
  import fs from 'fs';
19
19
  import path from 'path';
20
- import YAML from 'js-yaml';
20
+ import YAML from '../lib/js-yaml.mjs';
21
21
  import { findProjectRoot } from '../lib/find-root.mjs';
22
22
  import { parseFrontmatter, serializeFrontmatter } from '../lib/utils.mjs';
23
23