workflow-ai 1.0.55 → 1.0.57
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 +18 -8
- package/package.json +1 -1
- package/src/runner.mjs +23 -4
- package/src/scripts/archive-plan-tickets.js +102 -0
- package/src/scripts/check-anomalies.js +161 -0
- package/src/scripts/check-conditions.js +251 -0
- package/src/scripts/check-plan-decomposed.js +179 -0
- package/src/scripts/check-plan-templates.js +295 -0
- package/src/scripts/move-ticket.js +260 -0
- package/src/scripts/move-to-ready.js +115 -0
- package/src/scripts/move-to-review.js +142 -0
- package/src/scripts/pick-next-task.js +723 -0
- package/templates/ticket-template.md +3 -0
package/configs/pipeline.yaml
CHANGED
|
@@ -144,6 +144,9 @@ pipeline:
|
|
|
144
144
|
# counter: <string> — имя счётчика попыток для этого stage
|
|
145
145
|
# agent_by_type: — маршрутизация агента по типу задачи (task_type)
|
|
146
146
|
# <type>: <agent-id> — например: impl: claude-sonnet, arch: claude-opus
|
|
147
|
+
# <type>: — или расширенный формат с fallback_model:
|
|
148
|
+
# agent: <agent-id>
|
|
149
|
+
# fallback_model: <agent-id>
|
|
147
150
|
# agent_by_attempt: — ротация агента по номеру попытки
|
|
148
151
|
# <N>: <agent-id> — например: 1: qwen-code, 2: claude-sonnet
|
|
149
152
|
# goto: — логика переходов по результату
|
|
@@ -348,14 +351,21 @@ pipeline:
|
|
|
348
351
|
skill: execute-task
|
|
349
352
|
counter: task_attempts
|
|
350
353
|
agent_by_type:
|
|
351
|
-
coach:
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
354
|
+
coach:
|
|
355
|
+
agent: claude-coach
|
|
356
|
+
fallback_model: claude-sonnet
|
|
357
|
+
qa:
|
|
358
|
+
agent: claude-qa
|
|
359
|
+
fallback_model: claude-sonnet
|
|
360
|
+
impl:
|
|
361
|
+
agent: kilo-minimax
|
|
362
|
+
fallback_model: kilo-glm
|
|
363
|
+
arch:
|
|
364
|
+
agent: claude-opus
|
|
365
|
+
fallback_model: claude-sonnet
|
|
366
|
+
admin:
|
|
367
|
+
agent: kilo-minimax
|
|
368
|
+
fallback_model: kilo-glm
|
|
359
369
|
agent_by_attempt:
|
|
360
370
|
2: qwen-code
|
|
361
371
|
3: claude-sonnet
|
package/package.json
CHANGED
package/src/runner.mjs
CHANGED
|
@@ -796,6 +796,7 @@ class StageExecutor {
|
|
|
796
796
|
// 3. stage.agent — явно указанный агент stage
|
|
797
797
|
// 4. default_agent — глобальный дефолт
|
|
798
798
|
let agentId = stage.agent || this.pipeline.default_agent;
|
|
799
|
+
let fallbackModelId = stage.fallback_agent; // fallback_model из agent_by_type имеет приоритет
|
|
799
800
|
const attempt = (stage.counter && this.counters[stage.counter]) || 0;
|
|
800
801
|
|
|
801
802
|
// Фоллбэк: если task_type не задан, вычисляем из префикса ticket_id (PMA-005 → pma)
|
|
@@ -805,8 +806,17 @@ class StageExecutor {
|
|
|
805
806
|
|
|
806
807
|
if (attempt <= 1 && stage.agent_by_type && taskType) {
|
|
807
808
|
// Первая попытка: выбор по типу задачи
|
|
808
|
-
|
|
809
|
-
|
|
809
|
+
const agentConfig = stage.agent_by_type[taskType];
|
|
810
|
+
if (agentConfig) {
|
|
811
|
+
// Поддержка формата: { agent: string, fallback_model: string } или просто string
|
|
812
|
+
if (typeof agentConfig === 'object' && agentConfig.agent) {
|
|
813
|
+
agentId = agentConfig.agent;
|
|
814
|
+
if (agentConfig.fallback_model) {
|
|
815
|
+
fallbackModelId = agentConfig.fallback_model;
|
|
816
|
+
}
|
|
817
|
+
} else {
|
|
818
|
+
agentId = agentConfig;
|
|
819
|
+
}
|
|
810
820
|
if (this.logger) {
|
|
811
821
|
this.logger.info(`Agent by type: task_type="${taskType}" → ${agentId}`, stageId);
|
|
812
822
|
}
|
|
@@ -844,8 +854,8 @@ class StageExecutor {
|
|
|
844
854
|
this.fileGuard.takeSnapshot();
|
|
845
855
|
}
|
|
846
856
|
|
|
847
|
-
// Вызываем CLI-агента с поддержкой fallback
|
|
848
|
-
const result = await this.callAgentWithFallback(agent, prompt, stageId, stage.skill,
|
|
857
|
+
// Вызываем CLI-агента с поддержкой fallback (приоритет: fallback_model из agent_by_type > stage.fallback_agent)
|
|
858
|
+
const result = await this.callAgentWithFallback(agent, prompt, stageId, stage.skill, fallbackModelId);
|
|
849
859
|
|
|
850
860
|
// Логгируем завершение stage
|
|
851
861
|
if (this.logger) {
|
|
@@ -1020,6 +1030,15 @@ class StageExecutor {
|
|
|
1020
1030
|
}
|
|
1021
1031
|
this.logger.info(`OUTPUT ↑`, stageId);
|
|
1022
1032
|
}
|
|
1033
|
+
|
|
1034
|
+
// Логгируем stderr независимо от exit code
|
|
1035
|
+
if (stderr.trim()) {
|
|
1036
|
+
this.logger.warn(`STDERR ↓`, stageId);
|
|
1037
|
+
for (const line of stderr.trim().split('\n')) {
|
|
1038
|
+
this.logger.warn(` ${line}`, stageId);
|
|
1039
|
+
}
|
|
1040
|
+
this.logger.warn(`STDERR ↑`, stageId);
|
|
1041
|
+
}
|
|
1023
1042
|
}
|
|
1024
1043
|
|
|
1025
1044
|
// Парсим результат из вывода агента через ResultParser
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* archive-plan-tickets.js - Архивирует все done-тикеты указанного плана
|
|
5
|
+
*
|
|
6
|
+
* Использование:
|
|
7
|
+
* node archive-plan-tickets.js <plan_id>
|
|
8
|
+
*
|
|
9
|
+
* Пример:
|
|
10
|
+
* node archive-plan-tickets.js PLAN-002
|
|
11
|
+
* node archive-plan-tickets.js 2
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import { findProjectRoot } from 'workflow-ai/lib/find-root.mjs';
|
|
17
|
+
import { parseFrontmatter, serializeFrontmatter, normalizePlanId, extractPlanId, printResult } from 'workflow-ai/lib/utils.mjs';
|
|
18
|
+
|
|
19
|
+
const PROJECT_DIR = findProjectRoot();
|
|
20
|
+
const WORKFLOW_DIR = path.join(PROJECT_DIR, '.workflow');
|
|
21
|
+
const TICKETS_DIR = path.join(WORKFLOW_DIR, 'tickets');
|
|
22
|
+
const DONE_DIR = path.join(TICKETS_DIR, 'done');
|
|
23
|
+
const ARCHIVE_DIR = path.join(TICKETS_DIR, 'archive');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Архивирует все done-тикеты указанного плана
|
|
27
|
+
*/
|
|
28
|
+
function archivePlanTickets(planId) {
|
|
29
|
+
if (!planId) {
|
|
30
|
+
return { status: 'error', error: 'Missing plan_id' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!fs.existsSync(DONE_DIR)) {
|
|
34
|
+
return { status: 'ok', plan_id: planId, archived: 0, ticket_ids: '' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!fs.existsSync(ARCHIVE_DIR)) {
|
|
38
|
+
fs.mkdirSync(ARCHIVE_DIR, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const files = fs.readdirSync(DONE_DIR).filter(f => f.endsWith('.md') && f !== '.gitkeep.md');
|
|
42
|
+
const archived = [];
|
|
43
|
+
|
|
44
|
+
for (const file of files) {
|
|
45
|
+
const filePath = path.join(DONE_DIR, file);
|
|
46
|
+
try {
|
|
47
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
48
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
49
|
+
|
|
50
|
+
const ticketPlanId = normalizePlanId(frontmatter.parent_plan);
|
|
51
|
+
if (ticketPlanId !== planId) continue;
|
|
52
|
+
|
|
53
|
+
const ticketId = frontmatter.id || file.replace('.md', '');
|
|
54
|
+
|
|
55
|
+
frontmatter.updated_at = new Date().toISOString();
|
|
56
|
+
frontmatter.archived_at = new Date().toISOString();
|
|
57
|
+
|
|
58
|
+
const destPath = path.join(ARCHIVE_DIR, file);
|
|
59
|
+
fs.writeFileSync(destPath, serializeFrontmatter(frontmatter) + body, 'utf8');
|
|
60
|
+
fs.unlinkSync(filePath);
|
|
61
|
+
|
|
62
|
+
archived.push(ticketId);
|
|
63
|
+
console.log(`[ARCHIVE] ${ticketId}: done → archive`);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
console.error(`[ERROR] Failed to archive ${file}: ${e.message}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
status: 'ok',
|
|
71
|
+
plan_id: planId,
|
|
72
|
+
archived: archived.length,
|
|
73
|
+
ticket_ids: archived.join(',')
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Main entry point
|
|
78
|
+
const rawArgs = process.argv.slice(2);
|
|
79
|
+
let planId;
|
|
80
|
+
|
|
81
|
+
if (rawArgs.length >= 1) {
|
|
82
|
+
// Прямой вызов или pipeline context
|
|
83
|
+
const arg = rawArgs[0];
|
|
84
|
+
const planMatch = arg.match(/plan_id:\s*(\S+)/i);
|
|
85
|
+
planId = planMatch ? normalizePlanId(planMatch[1]) : normalizePlanId(arg);
|
|
86
|
+
} else {
|
|
87
|
+
planId = extractPlanId();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!planId) {
|
|
91
|
+
console.error('Usage: node archive-plan-tickets.js <plan_id>');
|
|
92
|
+
console.error('Example: node archive-plan-tickets.js PLAN-002');
|
|
93
|
+
printResult({ status: 'error', error: 'Missing plan_id argument' });
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const result = archivePlanTickets(planId);
|
|
98
|
+
printResult(result);
|
|
99
|
+
|
|
100
|
+
if (result.status === 'error') {
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* check-anomalies.js - Скрипт для проверки аномалий в тикетах
|
|
5
|
+
*
|
|
6
|
+
* Проверяет in-progress тикеты на наличие заполненных результатов.
|
|
7
|
+
* Если тикет в in-progress, но имеет заполненный раздел "Результат выполнения" —
|
|
8
|
+
* это аномалия (тикет, вероятно, выполнен, но не перемещён в done/review).
|
|
9
|
+
*
|
|
10
|
+
* Использование:
|
|
11
|
+
* node check-anomalies.js
|
|
12
|
+
*
|
|
13
|
+
* Выводит результат в формате:
|
|
14
|
+
* ---RESULT---
|
|
15
|
+
* status: ok|anomalies_found|error
|
|
16
|
+
* anomalies_count: N
|
|
17
|
+
* anomalies: [{"id": "IMPL-001", "title": "...", "recommendation": "..."}]
|
|
18
|
+
* ---RESULT---
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from 'fs';
|
|
22
|
+
import path from 'path';
|
|
23
|
+
import YAML from 'workflow-ai/lib/js-yaml.mjs';
|
|
24
|
+
import { findProjectRoot } from 'workflow-ai/lib/find-root.mjs';
|
|
25
|
+
import { parseFrontmatter, printResult } from 'workflow-ai/lib/utils.mjs';
|
|
26
|
+
|
|
27
|
+
// Корень проекта
|
|
28
|
+
const PROJECT_DIR = findProjectRoot();
|
|
29
|
+
const TICKETS_DIR = path.join(PROJECT_DIR, '.workflow', 'tickets');
|
|
30
|
+
const IN_PROGRESS_DIR = path.join(TICKETS_DIR, 'in-progress');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Проверяет, заполнен ли раздел результатов
|
|
34
|
+
* Возвращает true, если раздел содержит реальный контент (не только комментарии)
|
|
35
|
+
*/
|
|
36
|
+
function hasFilledResult(body) {
|
|
37
|
+
// Ищем раздел "Результат выполнения" или "Result"
|
|
38
|
+
// Используем более гибкий паттерн
|
|
39
|
+
const resultSectionRegex = /^##\s*(Результат выполнения|Result)\s*$/m;
|
|
40
|
+
const sectionStart = body.search(resultSectionRegex);
|
|
41
|
+
|
|
42
|
+
if (sectionStart === -1) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Находим начало следующей секции ## или конец файла
|
|
47
|
+
const nextSectionRegex = /^##\s+/gm;
|
|
48
|
+
nextSectionRegex.lastIndex = sectionStart + 1;
|
|
49
|
+
const nextSectionMatch = nextSectionRegex.exec(body);
|
|
50
|
+
const sectionEnd = nextSectionMatch ? nextSectionMatch.index : body.length;
|
|
51
|
+
|
|
52
|
+
const sectionContent = body.substring(sectionStart, sectionEnd);
|
|
53
|
+
|
|
54
|
+
// Ищем подраздел Summary или "Что сделано"
|
|
55
|
+
const summaryRegex = /^###\s*(Summary|Что сделано)\s*$/m;
|
|
56
|
+
const summaryStart = sectionContent.search(summaryRegex);
|
|
57
|
+
|
|
58
|
+
if (summaryStart === -1) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Находим начало следующего подраздела ### или конец секции
|
|
63
|
+
const nextSubsectionRegex = /^###\s+/gm;
|
|
64
|
+
nextSubsectionRegex.lastIndex = summaryStart + 1;
|
|
65
|
+
const nextSubsectionMatch = nextSubsectionRegex.exec(sectionContent);
|
|
66
|
+
const summaryEnd = nextSubsectionMatch ? nextSubsectionMatch.index : sectionContent.length;
|
|
67
|
+
|
|
68
|
+
const summaryContent = sectionContent.substring(summaryStart, summaryEnd);
|
|
69
|
+
|
|
70
|
+
// Проверяем, что контент не пустой и не состоит только из комментариев
|
|
71
|
+
// Удаляем HTML комментарии и проверяем остаток
|
|
72
|
+
const withoutComments = summaryContent.replace(/<!--[\s\S]*?-->/g, '').trim();
|
|
73
|
+
|
|
74
|
+
// Если после удаления комментариев остался текст — раздел заполнен
|
|
75
|
+
return withoutComments.length > 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Основная функция проверки аномалий
|
|
80
|
+
*/
|
|
81
|
+
async function checkAnomalies() {
|
|
82
|
+
const anomalies = [];
|
|
83
|
+
|
|
84
|
+
// Проверяем существование директории in-progress
|
|
85
|
+
if (!fs.existsSync(IN_PROGRESS_DIR)) {
|
|
86
|
+
return {
|
|
87
|
+
status: 'ok',
|
|
88
|
+
anomalies_count: 0,
|
|
89
|
+
anomalies: [],
|
|
90
|
+
message: 'in-progress directory does not exist'
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Читаем все файлы в in-progress
|
|
95
|
+
let files;
|
|
96
|
+
try {
|
|
97
|
+
files = fs.readdirSync(IN_PROGRESS_DIR);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return {
|
|
100
|
+
status: 'error',
|
|
101
|
+
error: `Failed to read in-progress directory: ${e.message}`
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Фильтруем .md файлы (исключаем .gitkeep)
|
|
106
|
+
const ticketFiles = files.filter(f => f.endsWith('.md') && f !== '.gitkeep.md');
|
|
107
|
+
|
|
108
|
+
for (const file of ticketFiles) {
|
|
109
|
+
const filePath = path.join(IN_PROGRESS_DIR, file);
|
|
110
|
+
let content;
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
114
|
+
} catch (e) {
|
|
115
|
+
anomalies.push({
|
|
116
|
+
id: file.replace('.md', ''),
|
|
117
|
+
title: 'Unknown (read error)',
|
|
118
|
+
recommendation: `Не удалось прочитать файл: ${e.message}`
|
|
119
|
+
});
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Парсим frontmatter для получения id и title
|
|
124
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
125
|
+
const ticketId = frontmatter.id || file.replace('.md', '');
|
|
126
|
+
const ticketTitle = frontmatter.title || 'Unknown';
|
|
127
|
+
|
|
128
|
+
// Проверяем наличие заполненного результата
|
|
129
|
+
if (hasFilledResult(body)) {
|
|
130
|
+
anomalies.push({
|
|
131
|
+
id: ticketId,
|
|
132
|
+
title: ticketTitle,
|
|
133
|
+
recommendation: 'Проверьте тикет и переместите в done/ или review/ если выполнен'
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
status: anomalies.length > 0 ? 'anomalies_found' : 'ok',
|
|
140
|
+
anomalies_count: anomalies.length,
|
|
141
|
+
anomalies: anomalies
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Main entry point
|
|
146
|
+
checkAnomalies().then(result => {
|
|
147
|
+
printResult(result);
|
|
148
|
+
|
|
149
|
+
// Если найдены аномалии, выводим их в читаемом виде
|
|
150
|
+
if (result.anomalies && result.anomalies.length > 0) {
|
|
151
|
+
console.log('\n[ANOMALIES DETECTED]');
|
|
152
|
+
for (const anomaly of result.anomalies) {
|
|
153
|
+
console.log(` - ${anomaly.id}: ${anomaly.title}`);
|
|
154
|
+
console.log(` Recommendation: ${anomaly.recommendation}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (result.status === 'error') {
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* check-conditions.js — Проверяет условия тикетов в backlog/ и выводит список готовых
|
|
5
|
+
*
|
|
6
|
+
* Использование:
|
|
7
|
+
* node check-conditions.js
|
|
8
|
+
*
|
|
9
|
+
* Выводит результат в формате:
|
|
10
|
+
* ---RESULT---
|
|
11
|
+
* status: has_ready
|
|
12
|
+
* ready_tickets: IMPL-002, DOCS-001
|
|
13
|
+
* ---RESULT---
|
|
14
|
+
*
|
|
15
|
+
* или если готовых нет, но есть тикеты в ready/:
|
|
16
|
+
* ---RESULT---
|
|
17
|
+
* status: default
|
|
18
|
+
* ready_tickets:
|
|
19
|
+
* ---RESULT---
|
|
20
|
+
*
|
|
21
|
+
* или если backlog пуст и нет тикетов в ready/:
|
|
22
|
+
* ---RESULT---
|
|
23
|
+
* status: empty
|
|
24
|
+
* ready_tickets:
|
|
25
|
+
* ---RESULT---
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import fs from 'fs';
|
|
29
|
+
import path from 'path';
|
|
30
|
+
import { findProjectRoot } from 'workflow-ai/lib/find-root.mjs';
|
|
31
|
+
import { parseFrontmatter, printResult, normalizePlanId, extractPlanId, serializeFrontmatter } from 'workflow-ai/lib/utils.mjs';
|
|
32
|
+
|
|
33
|
+
const PROJECT_DIR = findProjectRoot();
|
|
34
|
+
const WORKFLOW_DIR = path.join(PROJECT_DIR, '.workflow');
|
|
35
|
+
const TICKETS_DIR = path.join(WORKFLOW_DIR, 'tickets');
|
|
36
|
+
const BACKLOG_DIR = path.join(TICKETS_DIR, 'backlog');
|
|
37
|
+
const READY_DIR = path.join(TICKETS_DIR, 'ready');
|
|
38
|
+
const DONE_DIR = path.join(TICKETS_DIR, 'done');
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Проверяет одно условие тикета
|
|
42
|
+
*/
|
|
43
|
+
function checkCondition(condition) {
|
|
44
|
+
const { type, value } = condition;
|
|
45
|
+
|
|
46
|
+
switch (type) {
|
|
47
|
+
case 'file_exists':
|
|
48
|
+
return fs.existsSync(path.join(PROJECT_DIR, value));
|
|
49
|
+
|
|
50
|
+
case 'file_not_exists':
|
|
51
|
+
return !fs.existsSync(path.join(PROJECT_DIR, value));
|
|
52
|
+
|
|
53
|
+
case 'tasks_completed': {
|
|
54
|
+
if (!value || (Array.isArray(value) && value.length === 0)) return true;
|
|
55
|
+
const ids = Array.isArray(value) ? value : [value];
|
|
56
|
+
return ids.every(taskId => fs.existsSync(path.join(DONE_DIR, `${taskId}.md`)));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
case 'date_after':
|
|
60
|
+
return new Date() > new Date(value);
|
|
61
|
+
|
|
62
|
+
case 'date_before':
|
|
63
|
+
return new Date() < new Date(value);
|
|
64
|
+
|
|
65
|
+
case 'manual_approval':
|
|
66
|
+
return false;
|
|
67
|
+
|
|
68
|
+
default:
|
|
69
|
+
console.error(`[WARN] Unknown condition type: ${type}`);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Проверяет зависимости тикета
|
|
76
|
+
*/
|
|
77
|
+
function checkDependencies(dependencies) {
|
|
78
|
+
if (!dependencies || dependencies.length === 0) return true;
|
|
79
|
+
return dependencies.every(depId => fs.existsSync(path.join(DONE_DIR, `${depId}.md`)));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Считывает все тикеты из директории
|
|
84
|
+
*/
|
|
85
|
+
function readTickets(dir) {
|
|
86
|
+
if (!fs.existsSync(dir)) return [];
|
|
87
|
+
|
|
88
|
+
const tickets = [];
|
|
89
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.md') && f !== '.gitkeep.md');
|
|
90
|
+
|
|
91
|
+
for (const file of files) {
|
|
92
|
+
const filePath = path.join(dir, file);
|
|
93
|
+
try {
|
|
94
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
95
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
96
|
+
tickets.push({ id: frontmatter.id || file.replace('.md', ''), frontmatter });
|
|
97
|
+
} catch (e) {
|
|
98
|
+
console.error(`[WARN] Failed to read ticket ${file}: ${e.message}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return tickets;
|
|
103
|
+
}
|
|
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
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Проверяет все тикеты в backlog/ и возвращает список готовых
|
|
135
|
+
*/
|
|
136
|
+
function checkBacklog(planId) {
|
|
137
|
+
const allTickets = readTickets(BACKLOG_DIR);
|
|
138
|
+
const tickets = planId
|
|
139
|
+
? allTickets.filter(t => normalizePlanId(t.frontmatter.parent_plan) === planId)
|
|
140
|
+
: allTickets;
|
|
141
|
+
|
|
142
|
+
const ready = [];
|
|
143
|
+
const waiting = [];
|
|
144
|
+
|
|
145
|
+
for (const ticket of tickets) {
|
|
146
|
+
const { frontmatter, id } = ticket;
|
|
147
|
+
|
|
148
|
+
const conditions = frontmatter.conditions || [];
|
|
149
|
+
const dependencies = frontmatter.dependencies || [];
|
|
150
|
+
|
|
151
|
+
const depsMet = checkDependencies(dependencies);
|
|
152
|
+
const conditionsMet = conditions.every(checkCondition);
|
|
153
|
+
|
|
154
|
+
if (depsMet && conditionsMet) {
|
|
155
|
+
ready.push(id);
|
|
156
|
+
if (frontmatter.type === 'human') {
|
|
157
|
+
console.log(`[INFO] ${id}: type is 'human', moved to ready/ (requires manual execution)`);
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
const reasons = [];
|
|
161
|
+
if (!depsMet) reasons.push(`ждёт зависимости: ${dependencies.join(', ')}`);
|
|
162
|
+
conditions.forEach(c => {
|
|
163
|
+
if (!checkCondition(c)) reasons.push(`условие не выполнено: ${c.type}`);
|
|
164
|
+
});
|
|
165
|
+
waiting.push({ id, reasons });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { ready, waiting, total: tickets.length };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Проверяет тикеты в ready/ и возвращает тикеты в backlog при невыполненных условиях
|
|
174
|
+
*/
|
|
175
|
+
function checkReady(planId) {
|
|
176
|
+
const allTickets = readTickets(READY_DIR);
|
|
177
|
+
const tickets = planId
|
|
178
|
+
? allTickets.filter(t => normalizePlanId(t.frontmatter.parent_plan) === planId)
|
|
179
|
+
: allTickets;
|
|
180
|
+
|
|
181
|
+
const demoted = [];
|
|
182
|
+
|
|
183
|
+
for (const ticket of tickets) {
|
|
184
|
+
const { frontmatter, id } = ticket;
|
|
185
|
+
const conditions = frontmatter.conditions || [];
|
|
186
|
+
const dependencies = frontmatter.dependencies || [];
|
|
187
|
+
|
|
188
|
+
const depsMet = checkDependencies(dependencies);
|
|
189
|
+
const conditionsMet = conditions.every(checkCondition);
|
|
190
|
+
|
|
191
|
+
if (!depsMet || !conditionsMet) {
|
|
192
|
+
if (demoteToBacklog(id)) {
|
|
193
|
+
console.log(`[INFO] ${id}: ready/ → backlog/ (условия не выполнены)`);
|
|
194
|
+
demoted.push(id);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { demoted, total: tickets.length };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function main() {
|
|
203
|
+
const planId = extractPlanId();
|
|
204
|
+
|
|
205
|
+
if (planId) {
|
|
206
|
+
console.log(`[INFO] Filtering by plan_id: ${planId}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Сначала демотирование невалидных тикетов из ready/
|
|
210
|
+
console.log(`[INFO] Checking ready/ for invalid tickets: ${READY_DIR}`);
|
|
211
|
+
const { demoted, total: readyTotal } = checkReady(planId);
|
|
212
|
+
console.log(`[INFO] Total in ready/${planId ? ` (plan ${planId})` : ''}: ${readyTotal}`);
|
|
213
|
+
console.log(`[INFO] Demoted to backlog: ${demoted.length}`);
|
|
214
|
+
|
|
215
|
+
// Затем проверка backlog — демотированные тикеты сразу переоцениваются
|
|
216
|
+
console.log(`[INFO] Scanning backlog/: ${BACKLOG_DIR}`);
|
|
217
|
+
|
|
218
|
+
const { ready, waiting, total } = checkBacklog(planId);
|
|
219
|
+
|
|
220
|
+
console.log(`[INFO] Total in backlog${planId ? ` (plan ${planId})` : ''}: ${total}`);
|
|
221
|
+
console.log(`[INFO] Ready: ${ready.length}, Waiting: ${waiting.length}`);
|
|
222
|
+
|
|
223
|
+
if (ready.length > 0) {
|
|
224
|
+
console.log(`[INFO] Ready tickets: ${ready.join(', ')}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const { id, reasons } of waiting) {
|
|
228
|
+
console.log(`[INFO] ${id}: ${reasons.join('; ')}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (ready.length > 0) {
|
|
232
|
+
printResult({ status: 'has_ready', ready_tickets: ready.join(', '), demoted_tickets: demoted.join(', ') });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Нет готовых — проверяем есть ли что-то в ready/
|
|
237
|
+
const readyDirTickets = readTickets(READY_DIR);
|
|
238
|
+
if (readyDirTickets.length > 0) {
|
|
239
|
+
console.log(`[INFO] No new ready tickets, but ready/ has ${readyDirTickets.length} ticket(s)`);
|
|
240
|
+
printResult({ status: 'default', ready_tickets: '', demoted_tickets: demoted.join(', ') });
|
|
241
|
+
} else {
|
|
242
|
+
console.log('[INFO] No ready tickets and ready/ is empty');
|
|
243
|
+
printResult({ status: 'empty', ready_tickets: '', demoted_tickets: demoted.join(', ') });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
main().catch(e => {
|
|
248
|
+
console.error(`[ERROR] ${e.message}`);
|
|
249
|
+
printResult({ status: 'error', error: e.message });
|
|
250
|
+
process.exit(1);
|
|
251
|
+
});
|