workflow-ai 1.0.61 → 1.0.63

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,311 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * check-relevance.js - Скрипт проверки актуальности тикета
5
+ *
6
+ * Использование:
7
+ * node check-relevance.js <path-to-ticket>
8
+ *
9
+ * Вывод:
10
+ * ---RESULT---
11
+ * verdict: relevant|irrelevant
12
+ * reason: ...
13
+ * ---RESULT---
14
+ */
15
+
16
+ import fs from "fs";
17
+ import path from "path";
18
+ import YAML from "workflow-ai/lib/js-yaml.mjs";
19
+ import { findProjectRoot } from "workflow-ai/lib/find-root.mjs";
20
+ import {
21
+ parseFrontmatter,
22
+ serializeFrontmatter,
23
+ getLastReviewStatus,
24
+ } from "workflow-ai/lib/utils.mjs";
25
+
26
+ const PROJECT_DIR = findProjectRoot();
27
+ const WORKFLOW_DIR = path.join(PROJECT_DIR, ".workflow");
28
+ const TICKETS_DIR = path.join(WORKFLOW_DIR, "tickets");
29
+
30
+ const VALID_STATUSES = [
31
+ "backlog",
32
+ "ready",
33
+ "in-progress",
34
+ "blocked",
35
+ "review",
36
+ "done",
37
+ "archive",
38
+ ];
39
+
40
+ function getCurrentStatus(ticketPath) {
41
+ const fileName = path.basename(ticketPath);
42
+ for (const status of VALID_STATUSES) {
43
+ const statusDir = path.join(TICKETS_DIR, status);
44
+ const expectedPath = path.join(statusDir, fileName);
45
+ if (ticketPath === expectedPath) {
46
+ return status;
47
+ }
48
+ }
49
+ return null;
50
+ }
51
+
52
+ function extractPlanId(parentPlan) {
53
+ if (!parentPlan) return null;
54
+ const basename = path.basename(parentPlan, ".md");
55
+ const match = basename.match(/^PLAN-(\d+)$/i);
56
+ if (match) {
57
+ return `PLAN-${String(parseInt(match[1], 10)).padStart(3, "0")}`;
58
+ }
59
+ return basename;
60
+ }
61
+
62
+ function getDodCompletion(content) {
63
+ const dodSectionMatch = content.match(/## Критерии готовности.*?\n([\s\S]*?)(?=\n## |\n# |\z)/i);
64
+ if (!dodSectionMatch) return { completed: false, total: 0, checked: 0 };
65
+
66
+ const section = dodSectionMatch[1];
67
+ const checkedMatches = section.match(/\[x\]/gi) || [];
68
+ const uncheckedMatches = section.match(/\[ \]/gi) || [];
69
+
70
+ const total = checkedMatches.length + uncheckedMatches.length;
71
+ const completed = total > 0 && uncheckedMatches.length === 0;
72
+
73
+ return { completed, total, checked: checkedMatches.length };
74
+ }
75
+
76
+ function hasResultSection(content) {
77
+ const resultPatterns = [
78
+ /##\s*Result/gi,
79
+ /##\s*Результат/gi,
80
+ /##\s*Результат выполнения/gi,
81
+ ];
82
+ return resultPatterns.some((pattern) => pattern.test(content));
83
+ }
84
+
85
+ function getBlockedSection(content) {
86
+ const blockedMatch = content.match(/##\s*Блокировки\s*\n([\s\S]*?)(?=\n## |\n# |\z)/i);
87
+ return blockedMatch ? blockedMatch[1].trim() : "";
88
+ }
89
+
90
+ function findTicketInColumns(ticketId) {
91
+ for (const status of VALID_STATUSES) {
92
+ const statusDir = path.join(TICKETS_DIR, status);
93
+ const ticketPath = path.join(statusDir, `${ticketId}.md`);
94
+ if (fs.existsSync(ticketPath)) {
95
+ return status;
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+
101
+ async function checkRelevance(ticketPath) {
102
+ if (!fs.existsSync(ticketPath)) {
103
+ return {
104
+ verdict: "relevant",
105
+ reason: "file_not_found",
106
+ error: `Ticket file not found: ${ticketPath}`,
107
+ };
108
+ }
109
+
110
+ let content;
111
+ try {
112
+ content = fs.readFileSync(ticketPath, "utf8");
113
+ } catch (e) {
114
+ return {
115
+ verdict: "relevant",
116
+ reason: "read_error",
117
+ error: `Failed to read ticket: ${e.message}`,
118
+ };
119
+ }
120
+
121
+ let frontmatter, body;
122
+ try {
123
+ ({ frontmatter, body } = parseFrontmatter(content));
124
+ } catch (e) {
125
+ return {
126
+ verdict: "relevant",
127
+ reason: "invalid_frontmatter",
128
+ warning: `Failed to parse frontmatter: ${e.message}. Treating as relevant (fail-safe).`,
129
+ };
130
+ }
131
+
132
+ const currentStatus = getCurrentStatus(ticketPath);
133
+ const fullContent = body;
134
+
135
+ const lastReview = getLastReviewStatus(fullContent);
136
+ if (lastReview === "skipped") {
137
+ return { verdict: "irrelevant", reason: "already_skipped" };
138
+ }
139
+ if (lastReview === "failed") {
140
+ return { verdict: "relevant", reason: "review_failed_needs_rework" };
141
+ }
142
+
143
+ if (frontmatter.blocked === true || frontmatter.blocked === "true") {
144
+ return { verdict: "relevant", reason: "blocked" };
145
+ }
146
+
147
+ const blockedSection = getBlockedSection(fullContent);
148
+ const hasActiveBlockers = blockedSection.length > 0 && !blockedSection.includes("нет");
149
+ if (hasActiveBlockers) {
150
+ return { verdict: "relevant", reason: "blocked" };
151
+ }
152
+
153
+ const parentPlan = frontmatter.parent_plan;
154
+ if (parentPlan) {
155
+ const planId = extractPlanId(parentPlan);
156
+ if (planId) {
157
+ const planPath = path.join(WORKFLOW_DIR, "plans", "current", `${planId}.md`);
158
+ if (fs.existsSync(planPath)) {
159
+ try {
160
+ const planContent = fs.readFileSync(planPath, "utf8");
161
+ const { frontmatter: planFm } = parseFrontmatter(planContent);
162
+ const planStatus = planFm.status;
163
+ if (["completed", "archived", "cancelled"].includes(planStatus)) {
164
+ return { verdict: "irrelevant", reason: "plan_inactive" };
165
+ }
166
+ } catch (e) {
167
+ // fail-safe: treat as relevant
168
+ }
169
+ } else {
170
+ // fail-safe: treat as relevant
171
+ }
172
+ }
173
+ } else {
174
+ // fail-safe: parent_plan is empty
175
+ }
176
+
177
+ const dod = getDodCompletion(fullContent);
178
+ const hasResult = hasResultSection(fullContent);
179
+
180
+ if (dod.completed && hasResult) {
181
+ if (lastReview === "passed") {
182
+ return { verdict: "irrelevant", reason: "dod_completed" };
183
+ } else if (lastReview === null) {
184
+ return { verdict: "relevant", reason: "needs_review" };
185
+ }
186
+ }
187
+
188
+ const dependencies = frontmatter.dependencies || [];
189
+ if (dependencies.length > 0) {
190
+ for (const dep of dependencies) {
191
+ const depStatus = findTicketInColumns(dep);
192
+ if (depStatus === null) {
193
+ return { verdict: "irrelevant", reason: "dependencies_inactive" };
194
+ }
195
+ if (depStatus === "blocked") {
196
+ try {
197
+ const blockedDir = path.join(TICKETS_DIR, "blocked", `${dep}.md`);
198
+ const blockedContent = fs.readFileSync(blockedDir, "utf8");
199
+ const { body: blockedBody } = parseFrontmatter(blockedContent);
200
+ if (blockedBody.toLowerCase().includes("неактуально")) {
201
+ return { verdict: "irrelevant", reason: "dependencies_inactive" };
202
+ }
203
+ } catch (e) {
204
+ // ignore
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ return { verdict: "relevant", reason: "all_checks_passed" };
211
+ }
212
+
213
+ function addSkippedReview(ticketPath, reason) {
214
+ const now = new Date();
215
+ const date = now.toISOString().slice(0, 10);
216
+
217
+ let content;
218
+ try {
219
+ content = fs.readFileSync(ticketPath, "utf8");
220
+ } catch (e) {
221
+ throw new Error(`Failed to read ticket: ${e.message}`);
222
+ }
223
+
224
+ let { frontmatter, body } = parseFrontmatter(content);
225
+
226
+ const reviewSectionMatch = body.match(/##\s*Ревью\s*\n([\s\S]*)/i);
227
+ let newBody;
228
+
229
+ if (reviewSectionMatch) {
230
+ const reviewContent = reviewSectionMatch[1];
231
+ const lines = reviewContent.split("\n");
232
+ let insertIndex = 0;
233
+ for (let i = 0; i < lines.length; i++) {
234
+ if (lines[i].trim().startsWith("|") && lines[i].includes("---")) {
235
+ insertIndex = i + 1;
236
+ break;
237
+ }
238
+ }
239
+
240
+ const newRow = `| ${date} | ⏭️ skipped | ${reason} |`;
241
+ lines.splice(insertIndex, 0, newRow);
242
+ newBody = body.slice(0, reviewSectionMatch.index) + lines.join("\n");
243
+ } else {
244
+ const reviewTable = `\n## Ревью\n\n| Дата | Статус | Самари |\n|------|--------|--------|\n| ${date} | ⏭️ skipped | ${reason} |\n`;
245
+ newBody = body.trimEnd() + reviewTable;
246
+ }
247
+
248
+ const newContent = serializeFrontmatter(frontmatter) + newBody;
249
+ fs.writeFileSync(ticketPath, newContent, "utf8");
250
+ }
251
+
252
+ async function main() {
253
+ const args = process.argv.slice(2);
254
+ let ticketPath;
255
+
256
+ if (args.length === 0) {
257
+ console.error("Usage: node check-relevance.js <path-to-ticket>");
258
+ console.error("Example: node check-relevance.js .workflow/tickets/in-progress/IMPL-001.md");
259
+ process.exit(1);
260
+ } else if (args.length === 1) {
261
+ const arg = args[0];
262
+ const ticketMatch = arg.match(/ticket_id:\s*(\S+)/);
263
+ if (ticketMatch) {
264
+ const ticketId = ticketMatch[1];
265
+ ticketPath = path.join(TICKETS_DIR, "in-progress", `${ticketId}.md`);
266
+ } else if (/^[A-Z]+-\d+$/i.test(arg)) {
267
+ // Чистый ticket_id (например, IMPL-001) — резолвим в in-progress
268
+ ticketPath = path.join(TICKETS_DIR, "in-progress", `${arg}.md`);
269
+ } else {
270
+ ticketPath = arg;
271
+ }
272
+ } else {
273
+ ticketPath = args[0];
274
+ }
275
+
276
+ if (!path.isAbsolute(ticketPath)) {
277
+ ticketPath = path.resolve(process.cwd(), ticketPath);
278
+ }
279
+
280
+ const result = await checkRelevance(ticketPath);
281
+
282
+ if (result.warning && !result.error) {
283
+ console.error(`[WARNING] ${result.warning}`);
284
+ }
285
+
286
+ if (result.error) {
287
+ console.error(`[ERROR] ${result.error}`);
288
+ }
289
+
290
+ if (result.verdict === "irrelevant") {
291
+ try {
292
+ addSkippedReview(ticketPath, result.reason);
293
+ } catch (e) {
294
+ console.error(`[ERROR] Failed to add review entry: ${e.message}`);
295
+ }
296
+ }
297
+
298
+ console.log("---RESULT---");
299
+ console.log(`verdict: ${result.verdict}`);
300
+ console.log(`reason: ${result.reason}`);
301
+ console.log("---RESULT---");
302
+
303
+ if (result.error) {
304
+ process.exit(1);
305
+ }
306
+ }
307
+
308
+ main().catch((e) => {
309
+ console.error("[FATAL]", e.message);
310
+ process.exit(1);
311
+ });
@@ -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();