workflow-ai 1.0.61 → 1.0.62
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/agent-templates/CLAUDE.md.tpl +58 -58
- package/agent-templates/QWEN.md.tpl +58 -58
- package/package.json +1 -1
- package/src/init.mjs +437 -437
- package/src/runner.mjs +5 -4
- package/src/scripts/archive-plan-tickets.js +102 -0
- package/src/scripts/check-anomalies.js +161 -0
- package/src/scripts/check-conditions.js +258 -0
- package/src/scripts/check-mcp.js +277 -0
- package/src/scripts/check-plan-decomposed.js +217 -0
- package/src/scripts/check-plan-templates.js +297 -0
- package/src/scripts/check-relevance.js +311 -0
- package/src/scripts/complete-plan.js +106 -0
- package/src/scripts/get-next-id.js +214 -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 +151 -0
- package/src/scripts/pick-next-task.js +791 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* complete-plan.js — Завершает план (status: completed) и архивирует тикеты.
|
|
5
|
+
*
|
|
6
|
+
* Логика:
|
|
7
|
+
* 1. Если plan_id задан в контексте — используем его
|
|
8
|
+
* 2. Если plan_id пуст — ищем единственный активный план в plans/current/
|
|
9
|
+
* 3. Вызываем checkAndClosePlan: проверяет, все ли тикеты в done/archive,
|
|
10
|
+
* обновляет status → completed, архивирует done-тикеты
|
|
11
|
+
*
|
|
12
|
+
* Результаты:
|
|
13
|
+
* - status: completed — план успешно закрыт
|
|
14
|
+
* - status: not_ready — не все тикеты завершены
|
|
15
|
+
* - status: no_plan — план не найден
|
|
16
|
+
* - status: error — ошибка
|
|
17
|
+
*
|
|
18
|
+
* Использование:
|
|
19
|
+
* node complete-plan.js "plan_id: PLAN-009"
|
|
20
|
+
* node complete-plan.js PLAN-009
|
|
21
|
+
* node complete-plan.js (найдёт единственный активный план)
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'fs';
|
|
25
|
+
import path from 'path';
|
|
26
|
+
import { findProjectRoot } from 'workflow-ai/lib/find-root.mjs';
|
|
27
|
+
import { parseFrontmatter, printResult, normalizePlanId, extractPlanId, checkAndClosePlan } from 'workflow-ai/lib/utils.mjs';
|
|
28
|
+
|
|
29
|
+
const PROJECT_DIR = findProjectRoot();
|
|
30
|
+
const WORKFLOW_DIR = path.join(PROJECT_DIR, '.workflow');
|
|
31
|
+
const PLANS_DIR = path.join(WORKFLOW_DIR, 'plans', 'current');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Находит активный план в plans/current/ (status: active).
|
|
35
|
+
* Возвращает planId или null.
|
|
36
|
+
*/
|
|
37
|
+
function findActivePlan() {
|
|
38
|
+
if (!fs.existsSync(PLANS_DIR)) return null;
|
|
39
|
+
|
|
40
|
+
const files = fs.readdirSync(PLANS_DIR).filter(f => f.endsWith('.md'));
|
|
41
|
+
|
|
42
|
+
for (const file of files) {
|
|
43
|
+
try {
|
|
44
|
+
const content = fs.readFileSync(path.join(PLANS_DIR, file), 'utf8');
|
|
45
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
46
|
+
if (frontmatter.status === 'active') {
|
|
47
|
+
const planId = normalizePlanId(file);
|
|
48
|
+
if (planId) {
|
|
49
|
+
console.log(`[INFO] Found active plan: ${planId} (${file})`);
|
|
50
|
+
return planId;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} catch (_) { /* skip malformed */ }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Main entry point
|
|
60
|
+
const rawArgs = process.argv.slice(2);
|
|
61
|
+
let planId = null;
|
|
62
|
+
|
|
63
|
+
if (rawArgs.length >= 1) {
|
|
64
|
+
const arg = rawArgs[0];
|
|
65
|
+
const planMatch = arg.match(/plan_id:\s*(\S+)/i);
|
|
66
|
+
planId = planMatch ? normalizePlanId(planMatch[1]) : normalizePlanId(arg);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!planId) {
|
|
70
|
+
planId = extractPlanId();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!planId) {
|
|
74
|
+
console.log('[INFO] No plan_id in context, searching for active plan...');
|
|
75
|
+
planId = findActivePlan();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!planId) {
|
|
79
|
+
console.log('[INFO] No active plan found');
|
|
80
|
+
printResult({ status: 'no_plan' });
|
|
81
|
+
process.exit(0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
console.log(`[INFO] Completing plan: ${planId}`);
|
|
85
|
+
|
|
86
|
+
const result = checkAndClosePlan(WORKFLOW_DIR, planId);
|
|
87
|
+
|
|
88
|
+
if (result.closed) {
|
|
89
|
+
console.log(`[INFO] Plan ${planId} completed: ${result.done}/${result.total} tickets done, ${result.archived?.length || 0} archived`);
|
|
90
|
+
printResult({
|
|
91
|
+
status: 'completed',
|
|
92
|
+
plan_id: planId,
|
|
93
|
+
total: result.total,
|
|
94
|
+
done: result.done,
|
|
95
|
+
archived: result.archived?.length || 0
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
console.log(`[INFO] Plan ${planId} not closed: ${result.reason}`);
|
|
99
|
+
printResult({
|
|
100
|
+
status: 'not_ready',
|
|
101
|
+
plan_id: planId,
|
|
102
|
+
reason: result.reason,
|
|
103
|
+
total: result.total,
|
|
104
|
+
done: result.done
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* get-next-id.js - Универсальный генератор ID для тикетов, планов и других артефактов
|
|
5
|
+
*
|
|
6
|
+
* Режимы:
|
|
7
|
+
*
|
|
8
|
+
* 1) Одиночный префикс (legacy, для create-plan / create-report):
|
|
9
|
+
* node get-next-id.js --prefix TASK --dir tickets
|
|
10
|
+
* node get-next-id.js --prefix PLAN --dir plans
|
|
11
|
+
* Вывод: { status: "success", id: "TASK-007" }
|
|
12
|
+
*
|
|
13
|
+
* 2) Карта по всем префиксам из config.yaml (для decompose-plan):
|
|
14
|
+
* node get-next-id.js --all-from-config
|
|
15
|
+
* Читает .workflow/config/config.yaml → task_types.*.prefix,
|
|
16
|
+
* для каждого префикса возвращает следующий свободный номер по .workflow/tickets/.
|
|
17
|
+
* Вывод: { status: "success", id_ranges: { TASK: 8, QA: 26, HUMAN: 2, ... } }
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from "fs";
|
|
21
|
+
import path from "path";
|
|
22
|
+
import { findProjectRoot } from "workflow-ai/lib/find-root.mjs";
|
|
23
|
+
import { printResult } from "workflow-ai/lib/utils.mjs";
|
|
24
|
+
|
|
25
|
+
const PROJECT_DIR = findProjectRoot();
|
|
26
|
+
|
|
27
|
+
function parseArgs() {
|
|
28
|
+
const args = process.argv.slice(2);
|
|
29
|
+
let prefix = null;
|
|
30
|
+
let dir = null;
|
|
31
|
+
let allFromConfig = false;
|
|
32
|
+
|
|
33
|
+
for (let i = 0; i < args.length; i++) {
|
|
34
|
+
if (args[i] === "--prefix" && i + 1 < args.length) {
|
|
35
|
+
prefix = args[i + 1];
|
|
36
|
+
i++;
|
|
37
|
+
} else if (args[i] === "--dir" && i + 1 < args.length) {
|
|
38
|
+
dir = args[i + 1];
|
|
39
|
+
i++;
|
|
40
|
+
} else if (args[i] === "--all-from-config") {
|
|
41
|
+
allFromConfig = true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { prefix, dir, allFromConfig };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function findMaxNumber(targetDir, prefix) {
|
|
49
|
+
let maxNum = 0;
|
|
50
|
+
const regex = new RegExp(`^${prefix}-(\\d+)\\.md$`, "i");
|
|
51
|
+
|
|
52
|
+
function scanDirectory(dir) {
|
|
53
|
+
if (!fs.existsSync(dir)) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
58
|
+
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
const fullPath = path.join(dir, entry.name);
|
|
61
|
+
|
|
62
|
+
if (entry.isDirectory()) {
|
|
63
|
+
scanDirectory(fullPath);
|
|
64
|
+
} else if (entry.isFile()) {
|
|
65
|
+
const match = entry.name.match(regex);
|
|
66
|
+
if (match) {
|
|
67
|
+
const num = parseInt(match[1], 10);
|
|
68
|
+
if (num > maxNum) {
|
|
69
|
+
maxNum = num;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
scanDirectory(targetDir);
|
|
77
|
+
return maxNum;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatNumber(num) {
|
|
81
|
+
return num.toString().padStart(3, "0");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Извлекает значения prefix из .workflow/config/config.yaml → task_types.*.prefix.
|
|
86
|
+
* Минимальный YAML-парсер: не тянем зависимость, читаем только нужную секцию.
|
|
87
|
+
* Возвращает массив уникальных префиксов в порядке появления.
|
|
88
|
+
*/
|
|
89
|
+
function readPrefixesFromConfig() {
|
|
90
|
+
const configPath = path.join(PROJECT_DIR, ".workflow", "config", "config.yaml");
|
|
91
|
+
|
|
92
|
+
if (!fs.existsSync(configPath)) {
|
|
93
|
+
throw new Error(`config.yaml not found: ${configPath}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const text = fs.readFileSync(configPath, "utf8");
|
|
97
|
+
const lines = text.split(/\r?\n/);
|
|
98
|
+
|
|
99
|
+
const prefixes = [];
|
|
100
|
+
let inTaskTypes = false;
|
|
101
|
+
let taskTypesIndent = -1;
|
|
102
|
+
|
|
103
|
+
for (const rawLine of lines) {
|
|
104
|
+
// Убрать комментарии
|
|
105
|
+
const line = rawLine.replace(/\s+#.*$/, "").replace(/^#.*$/, "");
|
|
106
|
+
if (line.trim() === "") continue;
|
|
107
|
+
|
|
108
|
+
const indent = line.length - line.trimStart().length;
|
|
109
|
+
|
|
110
|
+
if (!inTaskTypes) {
|
|
111
|
+
if (/^task_types\s*:\s*$/.test(line.trim())) {
|
|
112
|
+
inTaskTypes = true;
|
|
113
|
+
taskTypesIndent = indent;
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Вышли из секции task_types (вернулись на тот же или меньший отступ с новым ключом)
|
|
119
|
+
if (indent <= taskTypesIndent && line.trim() !== "") {
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const m = line.match(/^\s*prefix\s*:\s*["']?([A-Z][A-Z0-9_]*)["']?\s*$/);
|
|
124
|
+
if (m) {
|
|
125
|
+
const p = m[1];
|
|
126
|
+
if (!prefixes.includes(p)) {
|
|
127
|
+
prefixes.push(p);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (prefixes.length === 0) {
|
|
133
|
+
throw new Error("No prefixes found in config.yaml → task_types.*.prefix");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return prefixes;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function resolveTargetDir(dir) {
|
|
140
|
+
if (dir === "tickets") {
|
|
141
|
+
return path.join(PROJECT_DIR, ".workflow", "tickets");
|
|
142
|
+
} else if (dir === "plans") {
|
|
143
|
+
return path.join(PROJECT_DIR, ".workflow", "plans");
|
|
144
|
+
} else {
|
|
145
|
+
return path.join(PROJECT_DIR, dir);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function runSinglePrefix(prefix, dir) {
|
|
150
|
+
const targetDir = resolveTargetDir(dir);
|
|
151
|
+
|
|
152
|
+
if (!fs.existsSync(targetDir)) {
|
|
153
|
+
const nextId = `${prefix}-001`;
|
|
154
|
+
printResult({ status: "success", id: nextId });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const maxNum = findMaxNumber(targetDir, prefix);
|
|
159
|
+
const nextNum = maxNum + 1;
|
|
160
|
+
const nextId = `${prefix}-${formatNumber(nextNum)}`;
|
|
161
|
+
|
|
162
|
+
printResult({ status: "success", id: nextId });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function runAllFromConfig() {
|
|
166
|
+
const prefixes = readPrefixesFromConfig();
|
|
167
|
+
const ticketsDir = path.join(PROJECT_DIR, ".workflow", "tickets");
|
|
168
|
+
|
|
169
|
+
const idRanges = {};
|
|
170
|
+
for (const prefix of prefixes) {
|
|
171
|
+
const maxNum = fs.existsSync(ticketsDir) ? findMaxNumber(ticketsDir, prefix) : 0;
|
|
172
|
+
idRanges[prefix] = maxNum + 1;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Параллельно возвращаем JSON-строку, потому что runner workflow-ai
|
|
176
|
+
// при подстановке $context.<key> в строку instructions применяет неявный
|
|
177
|
+
// toString() — объекты превращаются в "[object Object]". Скалярная
|
|
178
|
+
// JSON-строка подставляется корректно, декомпозитор распарсит её сам.
|
|
179
|
+
printResult({
|
|
180
|
+
status: "success",
|
|
181
|
+
id_ranges: idRanges,
|
|
182
|
+
id_ranges_json: JSON.stringify(idRanges),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function main() {
|
|
187
|
+
const { prefix, dir, allFromConfig } = parseArgs();
|
|
188
|
+
|
|
189
|
+
if (allFromConfig) {
|
|
190
|
+
try {
|
|
191
|
+
await runAllFromConfig();
|
|
192
|
+
} catch (err) {
|
|
193
|
+
console.error(err.message);
|
|
194
|
+
printResult({ status: "error", error: err.message });
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!prefix || !dir) {
|
|
201
|
+
console.error("Usage:");
|
|
202
|
+
console.error(" node get-next-id.js --prefix <PREFIX> --dir <DIRECTORY>");
|
|
203
|
+
console.error(" node get-next-id.js --all-from-config");
|
|
204
|
+
printResult({
|
|
205
|
+
status: "error",
|
|
206
|
+
error: "Missing required arguments: either --all-from-config, or both --prefix and --dir",
|
|
207
|
+
});
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await runSinglePrefix(prefix, dir);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
main();
|
|
@@ -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
|
+
});
|