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.
@@ -0,0 +1,260 @@
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 "workflow-ai/lib/js-yaml.mjs";
16
+ import { findProjectRoot } from "workflow-ai/lib/find-root.mjs";
17
+ import {
18
+ parseFrontmatter,
19
+ printResult,
20
+ serializeFrontmatter,
21
+ getLastReviewStatus,
22
+ } from "workflow-ai/lib/utils.mjs";
23
+
24
+ // Корень проекта
25
+ const PROJECT_DIR = findProjectRoot();
26
+ // Базовая директория workflow
27
+ const WORKFLOW_DIR = path.join(PROJECT_DIR, ".workflow");
28
+ const TICKETS_DIR = path.join(WORKFLOW_DIR, "tickets");
29
+
30
+ // Доступные статусы
31
+ const VALID_STATUSES = [
32
+ "backlog",
33
+ "ready",
34
+ "in-progress",
35
+ "blocked",
36
+ "review",
37
+ "done",
38
+ "archive",
39
+ ];
40
+
41
+ // Таблица допустимых переходов
42
+ const VALID_TRANSITIONS = {
43
+ backlog: ["ready", "blocked", "done"],
44
+ ready: ["in-progress", "review", "backlog"],
45
+ "in-progress": ["done", "blocked", "review"],
46
+ blocked: ["ready"],
47
+ review: ["done", "ready", "in-progress", "blocked"],
48
+ done: ["ready", "blocked", "archive"],
49
+ archive: ["backlog"],
50
+ };
51
+
52
+ /**
53
+ * Определяет текущий статус тикета по расположению файла
54
+ */
55
+ function getStatusFromPath(filePath) {
56
+ const fileName = path.basename(filePath);
57
+ for (const status of VALID_STATUSES) {
58
+ const statusDir = path.join(TICKETS_DIR, status);
59
+ const expectedPath = path.join(statusDir, fileName);
60
+ if (filePath === expectedPath) {
61
+ return status;
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+
67
+ /**
68
+ * Проверяет допустимость перехода
69
+ */
70
+ function isValidTransition(from, to) {
71
+ if (!VALID_STATUSES.includes(from)) {
72
+ return { valid: false, error: `Неверный исходный статус: ${from}` };
73
+ }
74
+ if (!VALID_STATUSES.includes(to)) {
75
+ return {
76
+ valid: false,
77
+ error: `Неверный целевой статус: ${to}. Доступные: ${VALID_STATUSES.join(", ")}`,
78
+ };
79
+ }
80
+
81
+ const allowedTransitions = VALID_TRANSITIONS[from] || [];
82
+ if (!allowedTransitions.includes(to)) {
83
+ return {
84
+ valid: false,
85
+ error: `Переход из ${from} в ${to} недопустим. Доступные переходы: ${allowedTransitions.join(", ") || "нет"}`,
86
+ };
87
+ }
88
+
89
+ return { valid: true };
90
+ }
91
+
92
+ /**
93
+ * Основная функция перемещения тикета
94
+ */
95
+ async function moveTicket(ticketId, target) {
96
+ // Поиск файла тикета во всех директориях
97
+ let sourceDir = null;
98
+ let currentStatus = null;
99
+
100
+ for (const status of VALID_STATUSES) {
101
+ const statusDir = path.join(TICKETS_DIR, status);
102
+ const ticketPath = path.join(statusDir, `${ticketId}.md`);
103
+ if (fs.existsSync(ticketPath)) {
104
+ sourceDir = statusDir;
105
+ currentStatus = status;
106
+ break;
107
+ }
108
+ }
109
+
110
+ if (!sourceDir) {
111
+ return {
112
+ status: "error",
113
+ ticket_id: ticketId,
114
+ error: `Тикет ${ticketId} не найден ни в одной из директорий`,
115
+ };
116
+ }
117
+
118
+ // Проверка допустимости перехода
119
+ const transitionCheck = isValidTransition(currentStatus, target);
120
+ if (!transitionCheck.valid) {
121
+ return {
122
+ status: "error",
123
+ ticket_id: ticketId,
124
+ from: currentStatus,
125
+ to: target,
126
+ error: transitionCheck.error,
127
+ };
128
+ }
129
+
130
+ const sourcePath = path.join(sourceDir, `${ticketId}.md`);
131
+ const targetDir = path.join(TICKETS_DIR, target);
132
+ const targetPath = path.join(targetDir, `${ticketId}.md`);
133
+
134
+ // Чтение файла тикета
135
+ let content;
136
+ try {
137
+ content = fs.readFileSync(sourcePath, "utf8");
138
+ } catch (e) {
139
+ return {
140
+ status: "error",
141
+ ticket_id: ticketId,
142
+ error: `Не удалось прочитать файл: ${e.message}`,
143
+ };
144
+ }
145
+
146
+ // Парсинг frontmatter
147
+ let frontmatter, body;
148
+ try {
149
+ ({ frontmatter, body } = parseFrontmatter(content));
150
+ } catch (e) {
151
+ return {
152
+ status: "error",
153
+ ticket_id: ticketId,
154
+ error: e.message,
155
+ };
156
+ }
157
+
158
+ // Обновление frontmatter
159
+ const now = new Date().toISOString();
160
+ frontmatter.updated_at = now;
161
+
162
+ // Если переход в done, добавляем completed_at
163
+ if (target === "done" && currentStatus !== "done") {
164
+ frontmatter.completed_at = now;
165
+ }
166
+
167
+ // Fallback: если тикет идёт в done из review, но агент не записал секцию "## Ревью" — дописываем
168
+ if (
169
+ target === "done" &&
170
+ currentStatus === "review" &&
171
+ getLastReviewStatus(content) === null
172
+ ) {
173
+ const date = now.slice(0, 16).replace("T", " ");
174
+ const reviewSection = `\n## Ревью\n\n| Дата | Статус | Самари |\n|------|--------|--------|\n| ${date} | ✅ passed | Pipeline fallback: агент не записал секцию ревью |\n`;
175
+ body = body.trimEnd() + "\n" + reviewSection;
176
+ }
177
+
178
+ // Если переход из blocked, удаляем blocked_reason
179
+ if (currentStatus === "blocked" && frontmatter.blocked_reason) {
180
+ delete frontmatter.blocked_reason;
181
+ }
182
+
183
+ // Сериализация нового контента
184
+ const newContent = serializeFrontmatter(frontmatter) + body;
185
+
186
+ // Создание целевой директории если не существует
187
+ if (!fs.existsSync(targetDir)) {
188
+ fs.mkdirSync(targetDir, { recursive: true });
189
+ }
190
+
191
+ // Перемещение файла
192
+ try {
193
+ fs.renameSync(sourcePath, targetPath);
194
+ } catch (e) {
195
+ return {
196
+ status: "error",
197
+ ticket_id: ticketId,
198
+ error: `Не удалось переместить файл: ${e.message}`,
199
+ };
200
+ }
201
+
202
+ // Запись обновлённого контента
203
+ try {
204
+ fs.writeFileSync(targetPath, newContent, "utf8");
205
+ } catch (e) {
206
+ return {
207
+ status: "error",
208
+ ticket_id: ticketId,
209
+ error: `Не удалось записать файл: ${e.message}`,
210
+ };
211
+ }
212
+
213
+ return {
214
+ status: "moved",
215
+ ticket_id: ticketId,
216
+ from: currentStatus,
217
+ to: target,
218
+ };
219
+ }
220
+
221
+ // Main entry point
222
+ const rawArgs = process.argv.slice(2);
223
+ let ticketId, target;
224
+
225
+ if (rawArgs.length >= 2) {
226
+ // Прямой вызов: node move-ticket.js IMPL-001 in-progress
227
+ ticketId = rawArgs[0];
228
+ target = rawArgs[1];
229
+ } else if (rawArgs.length === 1) {
230
+ // Вызов через pipeline runner: один аргумент — промпт с контекстом
231
+ // Формат: "skill-name\n\nContext:\n ticket_id: X\n target: Y\n..."
232
+ const prompt = rawArgs[0];
233
+ const ticketMatch = prompt.match(/ticket_id:\s*(\S+)/);
234
+ const targetMatch = prompt.match(/target:\s*(\S+)/);
235
+ ticketId = ticketMatch?.[1];
236
+ target = targetMatch?.[1];
237
+ if (!ticketId || !target) {
238
+ console.error(
239
+ "[ERROR] Cannot parse ticket_id or target from pipeline context",
240
+ );
241
+ printResult({
242
+ status: "error",
243
+ error: "Missing ticket_id or target in pipeline context",
244
+ });
245
+ process.exit(1);
246
+ }
247
+ } else {
248
+ console.error("Usage: node move-ticket.js <ticket_id> <target>");
249
+ console.error("Example: node move-ticket.js IMPL-001 in-progress");
250
+ console.error("Available targets:", VALID_STATUSES.join(", "));
251
+ printResult({ status: "error", error: "Missing arguments" });
252
+ process.exit(1);
253
+ }
254
+
255
+ moveTicket(ticketId, target).then((result) => {
256
+ printResult(result);
257
+ if (result.status === "error") {
258
+ process.exit(1);
259
+ }
260
+ });
@@ -0,0 +1,115 @@
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 'workflow-ai/lib/js-yaml.mjs';
21
+ import { findProjectRoot } from 'workflow-ai/lib/find-root.mjs';
22
+ import { parseFrontmatter, serializeFrontmatter } from 'workflow-ai/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
+ // Пропускаем тикеты, требующие ручного выполнения
55
+ if (frontmatter.type === 'human') {
56
+ console.log(`[INFO] ${ticketId}: type is 'human', skipping (requires manual execution)`);
57
+ return false;
58
+ }
59
+
60
+ frontmatter.updated_at = new Date().toISOString();
61
+
62
+ const newContent = serializeFrontmatter(frontmatter) + body;
63
+
64
+ if (!fs.existsSync(READY_DIR)) {
65
+ fs.mkdirSync(READY_DIR, { recursive: true });
66
+ }
67
+
68
+ fs.renameSync(sourcePath, targetPath);
69
+ fs.writeFileSync(targetPath, newContent, 'utf8');
70
+ return true;
71
+ }
72
+
73
+ function printResult(result) {
74
+ console.log('---RESULT---');
75
+ for (const [key, value] of Object.entries(result)) {
76
+ console.log(`${key}: ${value}`);
77
+ }
78
+ console.log('---RESULT---');
79
+ }
80
+
81
+ async function main() {
82
+ const rawArgs = process.argv.slice(2);
83
+ const prompt = rawArgs[0] || '';
84
+
85
+ const ticketIds = parseReadyTickets(prompt);
86
+
87
+ if (ticketIds.length === 0) {
88
+ console.log('[INFO] No tickets to move');
89
+ printResult({ status: 'default', moved: 0 });
90
+ return;
91
+ }
92
+
93
+ console.log(`[INFO] Moving ${ticketIds.length} ticket(s) to ready/`);
94
+
95
+ let moved = 0;
96
+ for (const id of ticketIds) {
97
+ try {
98
+ if (moveToReady(id)) {
99
+ console.log(`[INFO] ${id}: backlog/ → ready/`);
100
+ moved++;
101
+ }
102
+ } catch (e) {
103
+ console.error(`[ERROR] ${id}: ${e.message}`);
104
+ }
105
+ }
106
+
107
+ console.log(`[INFO] Moved: ${moved}/${ticketIds.length}`);
108
+ printResult({ status: moved > 0 ? 'moved' : 'default', moved });
109
+ }
110
+
111
+ main().catch(e => {
112
+ console.error(`[ERROR] ${e.message}`);
113
+ printResult({ status: 'error', error: e.message });
114
+ process.exit(1);
115
+ });
@@ -0,0 +1,142 @@
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 "workflow-ai/lib/find-root.mjs";
18
+ import {
19
+ parseFrontmatter,
20
+ serializeFrontmatter,
21
+ printResult,
22
+ getLastReviewStatus,
23
+ } from "workflow-ai/lib/utils.mjs";
24
+
25
+ // Корень проекта
26
+ const PROJECT_DIR = findProjectRoot();
27
+ const TICKETS_DIR = path.join(PROJECT_DIR, ".workflow", "tickets");
28
+ const IN_PROGRESS_DIR = path.join(TICKETS_DIR, "in-progress");
29
+ const REVIEW_DIR = path.join(TICKETS_DIR, "review");
30
+
31
+ /**
32
+ * Парсит ticket_id из промпта (контекста pipeline runner)
33
+ */
34
+ function parseTicketId(prompt) {
35
+ const match = prompt.match(/ticket_id:\s*(\S+)/);
36
+ return match ? match[1].trim() : null;
37
+ }
38
+
39
+ /**
40
+ * Перемещает тикет из in-progress/ в review/
41
+ */
42
+ function moveToReview(ticketId) {
43
+ const sourcePath = path.join(IN_PROGRESS_DIR, `${ticketId}.md`);
44
+ const targetPath = path.join(REVIEW_DIR, `${ticketId}.md`);
45
+
46
+ if (!fs.existsSync(sourcePath)) {
47
+ // Ticket may have been moved by the agent — check other locations
48
+ const reviewPath = path.join(REVIEW_DIR, `${ticketId}.md`);
49
+ if (fs.existsSync(reviewPath)) {
50
+ return {
51
+ status: "skipped",
52
+ ticket_id: ticketId,
53
+ reason: `${ticketId} already in review/`,
54
+ };
55
+ }
56
+ const donePath = path.join(TICKETS_DIR, "done", `${ticketId}.md`);
57
+ if (fs.existsSync(donePath)) {
58
+ // Проверяем, есть ли у тикета ревью — если нет, перемещаем в review/
59
+ const doneContent = fs.readFileSync(donePath, "utf8");
60
+ const reviewStatus = getLastReviewStatus(doneContent);
61
+ if (reviewStatus === null) {
62
+ // Тикет в done/ без ревью — агент переместил его самовольно, возвращаем в review/
63
+ if (!fs.existsSync(REVIEW_DIR)) {
64
+ fs.mkdirSync(REVIEW_DIR, { recursive: true });
65
+ }
66
+ const reviewTarget = path.join(REVIEW_DIR, `${ticketId}.md`);
67
+ fs.renameSync(donePath, reviewTarget);
68
+ console.log(
69
+ `[INFO] ${ticketId} was in done/ without review — moved to review/`,
70
+ );
71
+ return {
72
+ status: "moved",
73
+ ticket_id: ticketId,
74
+ from: "done",
75
+ to: "review",
76
+ };
77
+ }
78
+ return {
79
+ status: "skipped",
80
+ ticket_id: ticketId,
81
+ reason: `${ticketId} already in done/ with review`,
82
+ };
83
+ }
84
+ return {
85
+ status: "error",
86
+ ticket_id: ticketId,
87
+ error: `${ticketId} not found in in-progress/`,
88
+ };
89
+ }
90
+
91
+ const content = fs.readFileSync(sourcePath, "utf8");
92
+ const { frontmatter, body } = parseFrontmatter(content);
93
+
94
+ frontmatter.updated_at = new Date().toISOString();
95
+
96
+ const newContent = serializeFrontmatter(frontmatter) + body;
97
+
98
+ if (!fs.existsSync(REVIEW_DIR)) {
99
+ fs.mkdirSync(REVIEW_DIR, { recursive: true });
100
+ }
101
+
102
+ fs.renameSync(sourcePath, targetPath);
103
+ fs.writeFileSync(targetPath, newContent, "utf8");
104
+
105
+ return {
106
+ status: "moved",
107
+ ticket_id: ticketId,
108
+ from: "in-progress",
109
+ to: "review",
110
+ };
111
+ }
112
+
113
+ async function main() {
114
+ const rawArgs = process.argv.slice(2);
115
+ const prompt = rawArgs[0] || "";
116
+
117
+ const ticketId = parseTicketId(prompt);
118
+
119
+ if (!ticketId) {
120
+ console.error("[ERROR] No ticket_id in context");
121
+ printResult({ status: "error", error: "Missing ticket_id" });
122
+ process.exit(1);
123
+ }
124
+
125
+ console.log(`[INFO] Moving ${ticketId}: in-progress/ → review/`);
126
+ const result = moveToReview(ticketId);
127
+ printResult(result);
128
+
129
+ if (result.status === "error") {
130
+ process.exit(1);
131
+ }
132
+
133
+ if (result.status === "skipped") {
134
+ console.log(`[INFO] Skipped: ${result.reason}`);
135
+ }
136
+ }
137
+
138
+ main().catch((e) => {
139
+ console.error(`[ERROR] ${e.message}`);
140
+ printResult({ status: "error", error: e.message });
141
+ process.exit(1);
142
+ });