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.
- package/configs/ticket-movement-rules.yaml +83 -0
- package/package.json +1 -1
- package/src/init.mjs +16 -10
- package/src/lib/js-yaml.mjs +3856 -0
- package/src/lib/logger.mjs +73 -0
- package/src/lib/utils.mjs +108 -11
- package/src/runner.mjs +18 -17
- package/src/scripts/check-anomalies.js +1 -1
- package/src/scripts/move-ticket.js +1 -1
- package/src/scripts/move-to-ready.js +1 -1
- package/src/scripts/pick-next-task.js +219 -126
- package/src/skills/decompose-gaps/SKILL.md +1 -6
- package/src/skills/decompose-plan/SKILL.md +1 -8
|
@@ -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
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
if (bodyStart === -1) return null;
|
|
130
|
+
if (lastHeaderLineIndex === -1) return null;
|
|
125
131
|
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
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 === '
|
|
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
|
-
`│
|
|
246
|
-
`│
|
|
247
|
-
`│
|
|
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
|
'├─────────────────────────────────────────────────────────┤',
|
|
@@ -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
|
|