workflow-ai 1.0.10 → 1.0.12
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/pipeline.yaml +480 -440
- package/package.json +1 -1
- package/src/cli.mjs +130 -130
- package/src/lib/utils.mjs +74 -0
- package/src/runner.mjs +1518 -1492
- package/src/scripts/check-conditions.js +69 -4
- package/src/scripts/move-ticket.js +3 -3
- package/src/scripts/pick-next-task.js +177 -2
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
import fs from 'fs';
|
|
29
29
|
import path from 'path';
|
|
30
30
|
import { findProjectRoot } from '../lib/find-root.mjs';
|
|
31
|
-
import { parseFrontmatter, printResult, normalizePlanId, extractPlanId } from '../lib/utils.mjs';
|
|
31
|
+
import { parseFrontmatter, printResult, normalizePlanId, extractPlanId, serializeFrontmatter } from '../lib/utils.mjs';
|
|
32
32
|
|
|
33
33
|
const PROJECT_DIR = findProjectRoot();
|
|
34
34
|
const WORKFLOW_DIR = path.join(PROJECT_DIR, '.workflow');
|
|
@@ -102,6 +102,34 @@ function readTickets(dir) {
|
|
|
102
102
|
return tickets;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Перемещает тикет из ready/ в backlog/
|
|
107
|
+
*/
|
|
108
|
+
function demoteToBacklog(ticketId) {
|
|
109
|
+
const sourcePath = path.join(READY_DIR, `${ticketId}.md`);
|
|
110
|
+
const targetPath = path.join(BACKLOG_DIR, `${ticketId}.md`);
|
|
111
|
+
|
|
112
|
+
if (!fs.existsSync(sourcePath)) {
|
|
113
|
+
console.error(`[WARN] ${ticketId}: not found in ready/, skipping`);
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const content = fs.readFileSync(sourcePath, 'utf8');
|
|
118
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
119
|
+
|
|
120
|
+
frontmatter.updated_at = new Date().toISOString();
|
|
121
|
+
|
|
122
|
+
const newContent = serializeFrontmatter(frontmatter) + body;
|
|
123
|
+
|
|
124
|
+
if (!fs.existsSync(BACKLOG_DIR)) {
|
|
125
|
+
fs.mkdirSync(BACKLOG_DIR, { recursive: true });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
fs.renameSync(sourcePath, targetPath);
|
|
129
|
+
fs.writeFileSync(targetPath, newContent, 'utf8');
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
105
133
|
/**
|
|
106
134
|
* Проверяет все тикеты в backlog/ и возвращает список готовых
|
|
107
135
|
*/
|
|
@@ -137,6 +165,36 @@ function checkBacklog(planId) {
|
|
|
137
165
|
return { ready, waiting, total: tickets.length };
|
|
138
166
|
}
|
|
139
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Проверяет тикеты в ready/ и возвращает тикеты в backlog при невыполненных условиях
|
|
170
|
+
*/
|
|
171
|
+
function checkReady(planId) {
|
|
172
|
+
const allTickets = readTickets(READY_DIR);
|
|
173
|
+
const tickets = planId
|
|
174
|
+
? allTickets.filter(t => normalizePlanId(t.frontmatter.parent_plan) === planId)
|
|
175
|
+
: allTickets;
|
|
176
|
+
|
|
177
|
+
const demoted = [];
|
|
178
|
+
|
|
179
|
+
for (const ticket of tickets) {
|
|
180
|
+
const { frontmatter, id } = ticket;
|
|
181
|
+
const conditions = frontmatter.conditions || [];
|
|
182
|
+
const dependencies = frontmatter.dependencies || [];
|
|
183
|
+
|
|
184
|
+
const depsMet = checkDependencies(dependencies);
|
|
185
|
+
const conditionsMet = conditions.every(checkCondition);
|
|
186
|
+
|
|
187
|
+
if (!depsMet || !conditionsMet) {
|
|
188
|
+
if (demoteToBacklog(id)) {
|
|
189
|
+
console.log(`[INFO] ${id}: ready/ → backlog/ (условия не выполнены)`);
|
|
190
|
+
demoted.push(id);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { demoted, total: tickets.length };
|
|
196
|
+
}
|
|
197
|
+
|
|
140
198
|
async function main() {
|
|
141
199
|
const planId = extractPlanId();
|
|
142
200
|
|
|
@@ -144,6 +202,13 @@ async function main() {
|
|
|
144
202
|
console.log(`[INFO] Filtering by plan_id: ${planId}`);
|
|
145
203
|
}
|
|
146
204
|
|
|
205
|
+
// Сначала демотирование невалидных тикетов из ready/
|
|
206
|
+
console.log(`[INFO] Checking ready/ for invalid tickets: ${READY_DIR}`);
|
|
207
|
+
const { demoted, total: readyTotal } = checkReady(planId);
|
|
208
|
+
console.log(`[INFO] Total in ready/${planId ? ` (plan ${planId})` : ''}: ${readyTotal}`);
|
|
209
|
+
console.log(`[INFO] Demoted to backlog: ${demoted.length}`);
|
|
210
|
+
|
|
211
|
+
// Затем проверка backlog — демотированные тикеты сразу переоцениваются
|
|
147
212
|
console.log(`[INFO] Scanning backlog/: ${BACKLOG_DIR}`);
|
|
148
213
|
|
|
149
214
|
const { ready, waiting, total } = checkBacklog(planId);
|
|
@@ -160,7 +225,7 @@ async function main() {
|
|
|
160
225
|
}
|
|
161
226
|
|
|
162
227
|
if (ready.length > 0) {
|
|
163
|
-
printResult({ status: 'has_ready', ready_tickets: ready.join(', ') });
|
|
228
|
+
printResult({ status: 'has_ready', ready_tickets: ready.join(', '), demoted_tickets: demoted.join(', ') });
|
|
164
229
|
return;
|
|
165
230
|
}
|
|
166
231
|
|
|
@@ -168,10 +233,10 @@ async function main() {
|
|
|
168
233
|
const readyDirTickets = readTickets(READY_DIR);
|
|
169
234
|
if (readyDirTickets.length > 0) {
|
|
170
235
|
console.log(`[INFO] No new ready tickets, but ready/ has ${readyDirTickets.length} ticket(s)`);
|
|
171
|
-
printResult({ status: 'default', ready_tickets: '' });
|
|
236
|
+
printResult({ status: 'default', ready_tickets: '', demoted_tickets: demoted.join(', ') });
|
|
172
237
|
} else {
|
|
173
238
|
console.log('[INFO] No ready tickets and ready/ is empty');
|
|
174
|
-
printResult({ status: 'empty', ready_tickets: '' });
|
|
239
|
+
printResult({ status: 'empty', ready_tickets: '', demoted_tickets: demoted.join(', ') });
|
|
175
240
|
}
|
|
176
241
|
}
|
|
177
242
|
|
|
@@ -27,12 +27,12 @@ const VALID_STATUSES = ['backlog', 'ready', 'in-progress', 'blocked', 'review',
|
|
|
27
27
|
|
|
28
28
|
// Таблица допустимых переходов
|
|
29
29
|
const VALID_TRANSITIONS = {
|
|
30
|
-
'backlog': ['ready'],
|
|
31
|
-
'ready': ['in-progress', 'review'],
|
|
30
|
+
'backlog': ['ready', 'blocked', 'done'],
|
|
31
|
+
'ready': ['in-progress', 'review', 'backlog'],
|
|
32
32
|
'in-progress': ['done', 'blocked', 'review'],
|
|
33
33
|
'blocked': ['ready'],
|
|
34
34
|
'review': ['done', 'ready', 'in-progress', 'blocked'],
|
|
35
|
-
'done': []
|
|
35
|
+
'done': ['ready', 'blocked']
|
|
36
36
|
};
|
|
37
37
|
|
|
38
38
|
/**
|
|
@@ -21,7 +21,7 @@
|
|
|
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 } from '../lib/utils.mjs';
|
|
24
|
+
import { parseFrontmatter, printResult, normalizePlanId, extractPlanId, getLastReviewStatus, serializeFrontmatter } from '../lib/utils.mjs';
|
|
25
25
|
|
|
26
26
|
// Корень проекта
|
|
27
27
|
const PROJECT_DIR = findProjectRoot();
|
|
@@ -31,6 +31,9 @@ const TICKETS_DIR = path.join(WORKFLOW_DIR, 'tickets');
|
|
|
31
31
|
const READY_DIR = path.join(TICKETS_DIR, 'ready');
|
|
32
32
|
const DONE_DIR = path.join(TICKETS_DIR, 'done');
|
|
33
33
|
const IN_PROGRESS_DIR = path.join(TICKETS_DIR, 'in-progress');
|
|
34
|
+
const BLOCKED_DIR = path.join(TICKETS_DIR, 'blocked');
|
|
35
|
+
const REVIEW_DIR = path.join(TICKETS_DIR, 'review');
|
|
36
|
+
const BACKLOG_DIR = path.join(TICKETS_DIR, 'backlog');
|
|
34
37
|
|
|
35
38
|
|
|
36
39
|
/**
|
|
@@ -89,6 +92,164 @@ function checkDependencies(dependencies) {
|
|
|
89
92
|
});
|
|
90
93
|
}
|
|
91
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Авто-коррекция тикетов на основе статуса ревью.
|
|
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)
|
|
104
|
+
*
|
|
105
|
+
* @returns {object} Результат: { moved: Array<{id, from, to, reason}> }
|
|
106
|
+
*/
|
|
107
|
+
function autoCorrectTickets() {
|
|
108
|
+
const moved = [];
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Перемещает тикет из одной директории в другую
|
|
112
|
+
*/
|
|
113
|
+
function moveTicket(ticketId, fromDir, toDir, reason) {
|
|
114
|
+
const fromPath = path.join(fromDir, `${ticketId}.md`);
|
|
115
|
+
const toPath = path.join(toDir, `${ticketId}.md`);
|
|
116
|
+
|
|
117
|
+
if (!fs.existsSync(fromPath)) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
// Читаем содержимое
|
|
123
|
+
const content = fs.readFileSync(fromPath, 'utf8');
|
|
124
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
125
|
+
|
|
126
|
+
// Обновляем updated_at
|
|
127
|
+
frontmatter.updated_at = new Date().toISOString();
|
|
128
|
+
|
|
129
|
+
// Если перемещаем в done — ставим completed_at
|
|
130
|
+
if (toDir === DONE_DIR && !frontmatter.completed_at) {
|
|
131
|
+
frontmatter.completed_at = new Date().toISOString();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Сериализуем и записываем в новую директорию
|
|
135
|
+
const newContent = serializeFrontmatter(frontmatter) + body;
|
|
136
|
+
fs.writeFileSync(toPath, newContent, 'utf8');
|
|
137
|
+
|
|
138
|
+
// Удаляем старый файл
|
|
139
|
+
fs.unlinkSync(fromPath);
|
|
140
|
+
|
|
141
|
+
console.log(`[AUTO-CORRECT] ${ticketId}: ${path.basename(fromDir)} → ${path.basename(toDir)} (${reason})`);
|
|
142
|
+
|
|
143
|
+
moved.push({
|
|
144
|
+
id: ticketId,
|
|
145
|
+
from: path.basename(fromDir),
|
|
146
|
+
to: path.basename(toDir),
|
|
147
|
+
reason
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return true;
|
|
151
|
+
} catch (e) {
|
|
152
|
+
console.error(`[ERROR] Failed to move ticket ${ticketId}: ${e.message}`);
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Обрабатывает тикеты в указанной директории
|
|
159
|
+
*/
|
|
160
|
+
function processDirectory(dir, rules) {
|
|
161
|
+
if (!fs.existsSync(dir)) return;
|
|
162
|
+
|
|
163
|
+
const files = fs.readdirSync(dir)
|
|
164
|
+
.filter(f => f.endsWith('.md') && f !== '.gitkeep.md');
|
|
165
|
+
|
|
166
|
+
for (const file of files) {
|
|
167
|
+
const filePath = path.join(dir, file);
|
|
168
|
+
try {
|
|
169
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
170
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
171
|
+
const ticketId = frontmatter.id || file.replace('.md', '');
|
|
172
|
+
|
|
173
|
+
// Получаем статус ревью
|
|
174
|
+
const reviewStatus = getLastReviewStatus(content);
|
|
175
|
+
|
|
176
|
+
// Применяем правила
|
|
177
|
+
for (const rule of rules) {
|
|
178
|
+
if (rule.condition(reviewStatus)) {
|
|
179
|
+
moveTicket(ticketId, dir, rule.toDir, rule.reason);
|
|
180
|
+
break; // Только одно перемещение на тикет
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} catch (e) {
|
|
184
|
+
console.error(`[WARN] Failed to process ticket ${file}: ${e.message}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Правила для backlog/ (защита от ошибочного перемещения завершённых тикетов)
|
|
190
|
+
processDirectory(BACKLOG_DIR, [
|
|
191
|
+
{
|
|
192
|
+
condition: (status) => status === 'passed',
|
|
193
|
+
toDir: DONE_DIR,
|
|
194
|
+
reason: 'review passed'
|
|
195
|
+
}
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
// Правила для blocked/
|
|
199
|
+
processDirectory(BLOCKED_DIR, [
|
|
200
|
+
{
|
|
201
|
+
condition: (status) => status === 'passed',
|
|
202
|
+
toDir: DONE_DIR,
|
|
203
|
+
reason: 'review passed'
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
condition: (status) => status === 'failed',
|
|
207
|
+
toDir: BACKLOG_DIR,
|
|
208
|
+
reason: 'review failed'
|
|
209
|
+
}
|
|
210
|
+
// null (нет ревью) — не перемещаем
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
// Правила для done/
|
|
214
|
+
processDirectory(DONE_DIR, [
|
|
215
|
+
{
|
|
216
|
+
condition: (status) => status === 'failed',
|
|
217
|
+
toDir: BACKLOG_DIR,
|
|
218
|
+
reason: 'review failed'
|
|
219
|
+
}
|
|
220
|
+
// passed или null — не перемещаем (важно для legacy-тикетов без ревью)
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
// Правила для review/
|
|
224
|
+
processDirectory(REVIEW_DIR, [
|
|
225
|
+
{
|
|
226
|
+
condition: (status) => status === 'passed',
|
|
227
|
+
toDir: DONE_DIR,
|
|
228
|
+
reason: 'review passed'
|
|
229
|
+
}
|
|
230
|
+
]);
|
|
231
|
+
|
|
232
|
+
// Правила для in-progress/
|
|
233
|
+
processDirectory(IN_PROGRESS_DIR, [
|
|
234
|
+
{
|
|
235
|
+
condition: (status) => status === 'passed',
|
|
236
|
+
toDir: DONE_DIR,
|
|
237
|
+
reason: 'review passed'
|
|
238
|
+
}
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
// Правила для ready/
|
|
242
|
+
processDirectory(READY_DIR, [
|
|
243
|
+
{
|
|
244
|
+
condition: (status) => status === 'passed',
|
|
245
|
+
toDir: DONE_DIR,
|
|
246
|
+
reason: 'review passed'
|
|
247
|
+
}
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
return { moved };
|
|
251
|
+
}
|
|
252
|
+
|
|
92
253
|
/**
|
|
93
254
|
* Считывает все тикеты из директории ready/
|
|
94
255
|
*/
|
|
@@ -329,6 +490,13 @@ async function main() {
|
|
|
329
490
|
console.log(`[INFO] Filtering by plan_id: ${planId}`);
|
|
330
491
|
}
|
|
331
492
|
|
|
493
|
+
// Авто-коррекция тикетов перед выбором задачи
|
|
494
|
+
console.log('[INFO] Running auto-correction...');
|
|
495
|
+
const correctionResult = autoCorrectTickets();
|
|
496
|
+
if (correctionResult.moved.length > 0) {
|
|
497
|
+
console.log(`[INFO] Auto-corrected ${correctionResult.moved.length} ticket(s)`);
|
|
498
|
+
}
|
|
499
|
+
|
|
332
500
|
console.log(`[INFO] Scanning ready/ directory: ${READY_DIR}`);
|
|
333
501
|
|
|
334
502
|
const result = pickNextTicket(planId);
|
|
@@ -340,7 +508,14 @@ async function main() {
|
|
|
340
508
|
console.log(`[INFO] ${result.reason}`);
|
|
341
509
|
}
|
|
342
510
|
|
|
343
|
-
|
|
511
|
+
// Добавляем информацию о авто-коррекции в результат
|
|
512
|
+
const finalResult = {
|
|
513
|
+
...result,
|
|
514
|
+
auto_corrected: correctionResult.moved.length,
|
|
515
|
+
moved_tickets: correctionResult.moved.map(m => m.id).join(',')
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
printResult(finalResult);
|
|
344
519
|
|
|
345
520
|
if (result.status === 'empty') {
|
|
346
521
|
process.exit(0);
|