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.
@@ -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
- printResult(result);
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);