workflow-ai 1.0.0
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/README.md +76 -0
- package/agent-templates/CLAUDE.md.tpl +42 -0
- package/agent-templates/QWEN.md.tpl +34 -0
- package/bin/workflow.mjs +3 -0
- package/configs/config.yaml +117 -0
- package/configs/pipeline.yaml +422 -0
- package/package.json +24 -0
- package/src/cli.mjs +131 -0
- package/src/init.mjs +441 -0
- package/src/lib/find-root.mjs +33 -0
- package/src/lib/utils.mjs +66 -0
- package/src/runner.mjs +1466 -0
- package/src/scripts/check-anomalies.js +161 -0
- package/src/scripts/move-ticket.js +228 -0
- package/src/scripts/move-to-ready.js +110 -0
- package/src/scripts/move-to-review.js +88 -0
- package/src/scripts/pick-next-task.js +345 -0
- package/src/skills/analyze-report/SKILL.md +110 -0
- package/src/skills/check-conditions/SKILL.md +140 -0
- package/src/skills/create-plan/SKILL.md +98 -0
- package/src/skills/create-report/SKILL.md +156 -0
- package/src/skills/decompose-gaps/SKILL.md +122 -0
- package/src/skills/decompose-plan/SKILL.md +109 -0
- package/src/skills/execute-task/SKILL.md +117 -0
- package/src/skills/review-result/SKILL.md +274 -0
- package/templates/plan-template.md +116 -0
- package/templates/report-template.md +178 -0
- package/templates/ticket-template.md +103 -0
|
@@ -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 'js-yaml';
|
|
24
|
+
import { findProjectRoot } from '../lib/find-root.mjs';
|
|
25
|
+
import { parseFrontmatter, printResult } from '../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,228 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* move-ticket.js - Скрипт для перемещения тикетов между директориями канбан-доски
|
|
5
|
+
*
|
|
6
|
+
* Использование:
|
|
7
|
+
* node move-ticket.js <ticket_id> <target>
|
|
8
|
+
*
|
|
9
|
+
* Пример:
|
|
10
|
+
* node move-ticket.js IMPL-001 in-progress
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import YAML from 'js-yaml';
|
|
16
|
+
import { findProjectRoot } from '../lib/find-root.mjs';
|
|
17
|
+
import { parseFrontmatter, printResult, serializeFrontmatter } from '../lib/utils.mjs';
|
|
18
|
+
|
|
19
|
+
// Корень проекта
|
|
20
|
+
const PROJECT_DIR = findProjectRoot();
|
|
21
|
+
// Базовая директория workflow
|
|
22
|
+
const WORKFLOW_DIR = path.join(PROJECT_DIR, '.workflow');
|
|
23
|
+
const TICKETS_DIR = path.join(WORKFLOW_DIR, 'tickets');
|
|
24
|
+
|
|
25
|
+
// Доступные статусы
|
|
26
|
+
const VALID_STATUSES = ['backlog', 'ready', 'in-progress', 'blocked', 'review', 'done'];
|
|
27
|
+
|
|
28
|
+
// Таблица допустимых переходов
|
|
29
|
+
const VALID_TRANSITIONS = {
|
|
30
|
+
'backlog': ['ready'],
|
|
31
|
+
'ready': ['in-progress', 'review'],
|
|
32
|
+
'in-progress': ['done', 'blocked', 'review'],
|
|
33
|
+
'blocked': ['ready'],
|
|
34
|
+
'review': ['done', 'ready', 'in-progress', 'blocked'],
|
|
35
|
+
'done': []
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Определяет текущий статус тикета по расположению файла
|
|
40
|
+
*/
|
|
41
|
+
function getStatusFromPath(filePath) {
|
|
42
|
+
const fileName = path.basename(filePath);
|
|
43
|
+
for (const status of VALID_STATUSES) {
|
|
44
|
+
const statusDir = path.join(TICKETS_DIR, status);
|
|
45
|
+
const expectedPath = path.join(statusDir, fileName);
|
|
46
|
+
if (filePath === expectedPath) {
|
|
47
|
+
return status;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Проверяет допустимость перехода
|
|
55
|
+
*/
|
|
56
|
+
function isValidTransition(from, to) {
|
|
57
|
+
if (!VALID_STATUSES.includes(from)) {
|
|
58
|
+
return { valid: false, error: `Неверный исходный статус: ${from}` };
|
|
59
|
+
}
|
|
60
|
+
if (!VALID_STATUSES.includes(to)) {
|
|
61
|
+
return { valid: false, error: `Неверный целевой статус: ${to}. Доступные: ${VALID_STATUSES.join(', ')}` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const allowedTransitions = VALID_TRANSITIONS[from] || [];
|
|
65
|
+
if (!allowedTransitions.includes(to)) {
|
|
66
|
+
return {
|
|
67
|
+
valid: false,
|
|
68
|
+
error: `Переход из ${from} в ${to} недопустим. Доступные переходы: ${allowedTransitions.join(', ') || 'нет'}`
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { valid: true };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Основная функция перемещения тикета
|
|
77
|
+
*/
|
|
78
|
+
async function moveTicket(ticketId, target) {
|
|
79
|
+
// Поиск файла тикета во всех директориях
|
|
80
|
+
let sourceDir = null;
|
|
81
|
+
let currentStatus = null;
|
|
82
|
+
|
|
83
|
+
for (const status of VALID_STATUSES) {
|
|
84
|
+
const statusDir = path.join(TICKETS_DIR, status);
|
|
85
|
+
const ticketPath = path.join(statusDir, `${ticketId}.md`);
|
|
86
|
+
if (fs.existsSync(ticketPath)) {
|
|
87
|
+
sourceDir = statusDir;
|
|
88
|
+
currentStatus = status;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!sourceDir) {
|
|
94
|
+
return {
|
|
95
|
+
status: 'error',
|
|
96
|
+
ticket_id: ticketId,
|
|
97
|
+
error: `Тикет ${ticketId} не найден ни в одной из директорий`
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Проверка допустимости перехода
|
|
102
|
+
const transitionCheck = isValidTransition(currentStatus, target);
|
|
103
|
+
if (!transitionCheck.valid) {
|
|
104
|
+
return {
|
|
105
|
+
status: 'error',
|
|
106
|
+
ticket_id: ticketId,
|
|
107
|
+
from: currentStatus,
|
|
108
|
+
to: target,
|
|
109
|
+
error: transitionCheck.error
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const sourcePath = path.join(sourceDir, `${ticketId}.md`);
|
|
114
|
+
const targetDir = path.join(TICKETS_DIR, target);
|
|
115
|
+
const targetPath = path.join(targetDir, `${ticketId}.md`);
|
|
116
|
+
|
|
117
|
+
// Чтение файла тикета
|
|
118
|
+
let content;
|
|
119
|
+
try {
|
|
120
|
+
content = fs.readFileSync(sourcePath, 'utf8');
|
|
121
|
+
} catch (e) {
|
|
122
|
+
return {
|
|
123
|
+
status: 'error',
|
|
124
|
+
ticket_id: ticketId,
|
|
125
|
+
error: `Не удалось прочитать файл: ${e.message}`
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Парсинг frontmatter
|
|
130
|
+
let frontmatter, body;
|
|
131
|
+
try {
|
|
132
|
+
({ frontmatter, body } = parseFrontmatter(content));
|
|
133
|
+
} catch (e) {
|
|
134
|
+
return {
|
|
135
|
+
status: 'error',
|
|
136
|
+
ticket_id: ticketId,
|
|
137
|
+
error: e.message
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Обновление frontmatter
|
|
142
|
+
const now = new Date().toISOString();
|
|
143
|
+
frontmatter.status = target;
|
|
144
|
+
frontmatter.updated_at = now;
|
|
145
|
+
|
|
146
|
+
// Если переход в done, добавляем completed_at
|
|
147
|
+
if (target === 'done' && currentStatus !== 'done') {
|
|
148
|
+
frontmatter.completed_at = now;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Если переход из blocked, удаляем blocked_reason
|
|
152
|
+
if (currentStatus === 'blocked' && frontmatter.blocked_reason) {
|
|
153
|
+
delete frontmatter.blocked_reason;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Сериализация нового контента
|
|
157
|
+
const newContent = serializeFrontmatter(frontmatter) + body;
|
|
158
|
+
|
|
159
|
+
// Создание целевой директории если не существует
|
|
160
|
+
if (!fs.existsSync(targetDir)) {
|
|
161
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Перемещение файла
|
|
165
|
+
try {
|
|
166
|
+
fs.renameSync(sourcePath, targetPath);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
return {
|
|
169
|
+
status: 'error',
|
|
170
|
+
ticket_id: ticketId,
|
|
171
|
+
error: `Не удалось переместить файл: ${e.message}`
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Запись обновлённого контента
|
|
176
|
+
try {
|
|
177
|
+
fs.writeFileSync(targetPath, newContent, 'utf8');
|
|
178
|
+
} catch (e) {
|
|
179
|
+
return {
|
|
180
|
+
status: 'error',
|
|
181
|
+
ticket_id: ticketId,
|
|
182
|
+
error: `Не удалось записать файл: ${e.message}`
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
status: 'moved',
|
|
188
|
+
ticket_id: ticketId,
|
|
189
|
+
from: currentStatus,
|
|
190
|
+
to: target
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Main entry point
|
|
195
|
+
const rawArgs = process.argv.slice(2);
|
|
196
|
+
let ticketId, target;
|
|
197
|
+
|
|
198
|
+
if (rawArgs.length >= 2) {
|
|
199
|
+
// Прямой вызов: node move-ticket.js IMPL-001 in-progress
|
|
200
|
+
ticketId = rawArgs[0];
|
|
201
|
+
target = rawArgs[1];
|
|
202
|
+
} else if (rawArgs.length === 1) {
|
|
203
|
+
// Вызов через pipeline runner: один аргумент — промпт с контекстом
|
|
204
|
+
// Формат: "skill-name\n\nContext:\n ticket_id: X\n target: Y\n..."
|
|
205
|
+
const prompt = rawArgs[0];
|
|
206
|
+
const ticketMatch = prompt.match(/ticket_id:\s*(\S+)/);
|
|
207
|
+
const targetMatch = prompt.match(/target:\s*(\S+)/);
|
|
208
|
+
ticketId = ticketMatch?.[1];
|
|
209
|
+
target = targetMatch?.[1];
|
|
210
|
+
if (!ticketId || !target) {
|
|
211
|
+
console.error('[ERROR] Cannot parse ticket_id or target from pipeline context');
|
|
212
|
+
printResult({ status: 'error', error: 'Missing ticket_id or target in pipeline context' });
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
console.error('Usage: node move-ticket.js <ticket_id> <target>');
|
|
217
|
+
console.error('Example: node move-ticket.js IMPL-001 in-progress');
|
|
218
|
+
console.error('Available targets:', VALID_STATUSES.join(', '));
|
|
219
|
+
printResult({ status: 'error', error: 'Missing arguments' });
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
moveTicket(ticketId, target).then(result => {
|
|
224
|
+
printResult(result);
|
|
225
|
+
if (result.status === 'error') {
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* move-to-ready.js — Перемещает тикеты из backlog/ в ready/
|
|
5
|
+
*
|
|
6
|
+
* Читает список ticket IDs из контекста (поле ready_tickets),
|
|
7
|
+
* переданного pipeline runner'ом, и перемещает каждый тикет.
|
|
8
|
+
*
|
|
9
|
+
* Формат ready_tickets: "IMPL-002, DOCS-001" (через запятую)
|
|
10
|
+
*
|
|
11
|
+
* Выводит результат:
|
|
12
|
+
* ---RESULT---
|
|
13
|
+
* status: moved | default
|
|
14
|
+
* moved: 2
|
|
15
|
+
* ---RESULT---
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'fs';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import YAML from 'js-yaml';
|
|
21
|
+
import { findProjectRoot } from '../lib/find-root.mjs';
|
|
22
|
+
import { parseFrontmatter, serializeFrontmatter } from '../lib/utils.mjs';
|
|
23
|
+
|
|
24
|
+
// Корень проекта
|
|
25
|
+
const PROJECT_DIR = findProjectRoot();
|
|
26
|
+
const TICKETS_DIR = path.join(PROJECT_DIR, '.workflow', 'tickets');
|
|
27
|
+
const BACKLOG_DIR = path.join(TICKETS_DIR, 'backlog');
|
|
28
|
+
const READY_DIR = path.join(TICKETS_DIR, 'ready');
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Парсит список ticket IDs из промпта (контекста pipeline runner)
|
|
32
|
+
*/
|
|
33
|
+
function parseReadyTickets(prompt) {
|
|
34
|
+
const match = prompt.match(/ready_tickets:\s*(.+)/);
|
|
35
|
+
if (!match || !match[1].trim()) return [];
|
|
36
|
+
return match[1].split(',').map(id => id.trim()).filter(Boolean);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Перемещает один тикет из backlog/ в ready/
|
|
41
|
+
*/
|
|
42
|
+
function moveToReady(ticketId) {
|
|
43
|
+
const sourcePath = path.join(BACKLOG_DIR, `${ticketId}.md`);
|
|
44
|
+
const targetPath = path.join(READY_DIR, `${ticketId}.md`);
|
|
45
|
+
|
|
46
|
+
if (!fs.existsSync(sourcePath)) {
|
|
47
|
+
console.error(`[WARN] ${ticketId}: not found in backlog/, skipping`);
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const content = fs.readFileSync(sourcePath, 'utf8');
|
|
52
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
53
|
+
|
|
54
|
+
frontmatter.status = 'ready';
|
|
55
|
+
frontmatter.updated_at = new Date().toISOString();
|
|
56
|
+
|
|
57
|
+
const newContent = serializeFrontmatter(frontmatter) + body;
|
|
58
|
+
|
|
59
|
+
if (!fs.existsSync(READY_DIR)) {
|
|
60
|
+
fs.mkdirSync(READY_DIR, { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
fs.renameSync(sourcePath, targetPath);
|
|
64
|
+
fs.writeFileSync(targetPath, newContent, 'utf8');
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function printResult(result) {
|
|
69
|
+
console.log('---RESULT---');
|
|
70
|
+
for (const [key, value] of Object.entries(result)) {
|
|
71
|
+
console.log(`${key}: ${value}`);
|
|
72
|
+
}
|
|
73
|
+
console.log('---RESULT---');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function main() {
|
|
77
|
+
const rawArgs = process.argv.slice(2);
|
|
78
|
+
const prompt = rawArgs[0] || '';
|
|
79
|
+
|
|
80
|
+
const ticketIds = parseReadyTickets(prompt);
|
|
81
|
+
|
|
82
|
+
if (ticketIds.length === 0) {
|
|
83
|
+
console.log('[INFO] No tickets to move');
|
|
84
|
+
printResult({ status: 'default', moved: 0 });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.log(`[INFO] Moving ${ticketIds.length} ticket(s) to ready/`);
|
|
89
|
+
|
|
90
|
+
let moved = 0;
|
|
91
|
+
for (const id of ticketIds) {
|
|
92
|
+
try {
|
|
93
|
+
if (moveToReady(id)) {
|
|
94
|
+
console.log(`[INFO] ${id}: backlog/ → ready/`);
|
|
95
|
+
moved++;
|
|
96
|
+
}
|
|
97
|
+
} catch (e) {
|
|
98
|
+
console.error(`[ERROR] ${id}: ${e.message}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log(`[INFO] Moved: ${moved}/${ticketIds.length}`);
|
|
103
|
+
printResult({ status: moved > 0 ? 'moved' : 'default', moved });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
main().catch(e => {
|
|
107
|
+
console.error(`[ERROR] ${e.message}`);
|
|
108
|
+
printResult({ status: 'error', error: e.message });
|
|
109
|
+
process.exit(1);
|
|
110
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* move-to-review.js — Перемещает тикет из in-progress/ в review/
|
|
5
|
+
*
|
|
6
|
+
* Читает ticket_id из контекста pipeline runner'а.
|
|
7
|
+
*
|
|
8
|
+
* Выводит результат:
|
|
9
|
+
* ---RESULT---
|
|
10
|
+
* status: moved | error
|
|
11
|
+
* ticket_id: IMPL-001
|
|
12
|
+
* ---RESULT---
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import { findProjectRoot } from '../lib/find-root.mjs';
|
|
18
|
+
import { parseFrontmatter, serializeFrontmatter, printResult } from '../lib/utils.mjs';
|
|
19
|
+
|
|
20
|
+
// Корень проекта
|
|
21
|
+
const PROJECT_DIR = findProjectRoot();
|
|
22
|
+
const TICKETS_DIR = path.join(PROJECT_DIR, '.workflow', 'tickets');
|
|
23
|
+
const IN_PROGRESS_DIR = path.join(TICKETS_DIR, 'in-progress');
|
|
24
|
+
const REVIEW_DIR = path.join(TICKETS_DIR, 'review');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Парсит ticket_id из промпта (контекста pipeline runner)
|
|
28
|
+
*/
|
|
29
|
+
function parseTicketId(prompt) {
|
|
30
|
+
const match = prompt.match(/ticket_id:\s*(\S+)/);
|
|
31
|
+
return match ? match[1].trim() : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Перемещает тикет из in-progress/ в review/
|
|
36
|
+
*/
|
|
37
|
+
function moveToReview(ticketId) {
|
|
38
|
+
const sourcePath = path.join(IN_PROGRESS_DIR, `${ticketId}.md`);
|
|
39
|
+
const targetPath = path.join(REVIEW_DIR, `${ticketId}.md`);
|
|
40
|
+
|
|
41
|
+
if (!fs.existsSync(sourcePath)) {
|
|
42
|
+
return { status: 'error', ticket_id: ticketId, error: `${ticketId} not found in in-progress/` };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const content = fs.readFileSync(sourcePath, 'utf8');
|
|
46
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
47
|
+
|
|
48
|
+
frontmatter.status = 'review';
|
|
49
|
+
frontmatter.updated_at = new Date().toISOString();
|
|
50
|
+
|
|
51
|
+
const newContent = serializeFrontmatter(frontmatter) + body;
|
|
52
|
+
|
|
53
|
+
if (!fs.existsSync(REVIEW_DIR)) {
|
|
54
|
+
fs.mkdirSync(REVIEW_DIR, { recursive: true });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
fs.renameSync(sourcePath, targetPath);
|
|
58
|
+
fs.writeFileSync(targetPath, newContent, 'utf8');
|
|
59
|
+
|
|
60
|
+
return { status: 'moved', ticket_id: ticketId, from: 'in-progress', to: 'review' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function main() {
|
|
64
|
+
const rawArgs = process.argv.slice(2);
|
|
65
|
+
const prompt = rawArgs[0] || '';
|
|
66
|
+
|
|
67
|
+
const ticketId = parseTicketId(prompt);
|
|
68
|
+
|
|
69
|
+
if (!ticketId) {
|
|
70
|
+
console.error('[ERROR] No ticket_id in context');
|
|
71
|
+
printResult({ status: 'error', error: 'Missing ticket_id' });
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log(`[INFO] Moving ${ticketId}: in-progress/ → review/`);
|
|
76
|
+
const result = moveToReview(ticketId);
|
|
77
|
+
printResult(result);
|
|
78
|
+
|
|
79
|
+
if (result.status === 'error') {
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
main().catch(e => {
|
|
85
|
+
console.error(`[ERROR] ${e.message}`);
|
|
86
|
+
printResult({ status: 'error', error: e.message });
|
|
87
|
+
process.exit(1);
|
|
88
|
+
});
|