workflow-ai 1.0.60 → 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 +1735 -1713
- 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
- package/templates/ticket-template.md +3 -10
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* check-plan-templates.js — Проверяет шаблоны планов и создаёт планы по триггерам.
|
|
5
|
+
*
|
|
6
|
+
* Логика:
|
|
7
|
+
* 1. Читает все .md из plans/templates/
|
|
8
|
+
* 2. Для каждого с enabled: true — проверяет триггер
|
|
9
|
+
* 3. Если триггер сработал — создаёт план в plans/current/ со статусом approved
|
|
10
|
+
* 4. Обновляет last_triggered в шаблоне
|
|
11
|
+
*
|
|
12
|
+
* Типы триггеров:
|
|
13
|
+
* - daily — раз в день
|
|
14
|
+
* - weekly — в указанные дни недели (params.days_of_week: [0-6])
|
|
15
|
+
* - date_after — однократно после указанной даты (params.date)
|
|
16
|
+
* - interval_days — каждые N дней (params.days)
|
|
17
|
+
*
|
|
18
|
+
* Результат:
|
|
19
|
+
* - plan_created + plan_ids — созданы планы
|
|
20
|
+
* - no_triggers — ни один триггер не сработал
|
|
21
|
+
* - error — ошибка
|
|
22
|
+
*
|
|
23
|
+
* Использование:
|
|
24
|
+
* node check-plan-templates.js
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import fs from 'fs';
|
|
28
|
+
import path from 'path';
|
|
29
|
+
import { findProjectRoot } from 'workflow-ai/lib/find-root.mjs';
|
|
30
|
+
import { parseFrontmatter, serializeFrontmatter, printResult } from 'workflow-ai/lib/utils.mjs';
|
|
31
|
+
|
|
32
|
+
const PROJECT_DIR = findProjectRoot();
|
|
33
|
+
const WORKFLOW_DIR = path.join(PROJECT_DIR, '.workflow');
|
|
34
|
+
const TEMPLATES_DIR = path.join(WORKFLOW_DIR, 'plans', 'templates');
|
|
35
|
+
const PLANS_DIR = path.join(WORKFLOW_DIR, 'plans', 'current');
|
|
36
|
+
const TICKETS_DIR = path.join(WORKFLOW_DIR, 'tickets');
|
|
37
|
+
const DONE_DIR = 'done';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Проверяет, сработал ли триггер шаблона.
|
|
41
|
+
*
|
|
42
|
+
* @param {{ type: string, params?: object }} trigger — конфигурация триггера
|
|
43
|
+
* @param {string} lastTriggered — ISO-дата последнего срабатывания (или пустая строка)
|
|
44
|
+
* @param {Date} [now] — текущая дата (для тестов)
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
export function evaluateTrigger(trigger, lastTriggered, now = new Date()) {
|
|
48
|
+
if (!trigger || !trigger.type) return false;
|
|
49
|
+
|
|
50
|
+
const todayStr = now.toISOString().slice(0, 10);
|
|
51
|
+
const lastDate = lastTriggered ? String(lastTriggered).slice(0, 10) : null;
|
|
52
|
+
|
|
53
|
+
switch (trigger.type) {
|
|
54
|
+
case 'daily':
|
|
55
|
+
return lastDate !== todayStr;
|
|
56
|
+
|
|
57
|
+
case 'weekly': {
|
|
58
|
+
const dayOfWeek = now.getDay();
|
|
59
|
+
const targetDays = trigger.params?.days_of_week || [1];
|
|
60
|
+
if (!targetDays.includes(dayOfWeek)) return false;
|
|
61
|
+
return lastDate !== todayStr;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
case 'date_after': {
|
|
65
|
+
const targetDate = trigger.params?.date;
|
|
66
|
+
if (!targetDate) return false;
|
|
67
|
+
if (todayStr < targetDate) return false;
|
|
68
|
+
return !lastDate || lastDate < targetDate;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
case 'interval_days': {
|
|
72
|
+
const intervalDays = trigger.params?.days || 1;
|
|
73
|
+
if (!lastDate) return true;
|
|
74
|
+
const lastTime = new Date(lastDate).getTime();
|
|
75
|
+
const elapsed = (now.getTime() - lastTime) / (1000 * 60 * 60 * 24);
|
|
76
|
+
return elapsed >= intervalDays;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
default:
|
|
80
|
+
console.error(`[WARN] Unknown trigger type: ${trigger.type}`);
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Генерирует следующий ID плана (PLAN-NNN), сканируя plans/current/.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} plansDir — путь к директории plans/current/
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
export function generateNextPlanId(plansDir) {
|
|
92
|
+
const archiveDir = path.join(path.dirname(plansDir), 'archive');
|
|
93
|
+
let maxNum = 0;
|
|
94
|
+
|
|
95
|
+
for (const dir of [plansDir, archiveDir]) {
|
|
96
|
+
if (!fs.existsSync(dir)) continue;
|
|
97
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
|
|
98
|
+
for (const file of files) {
|
|
99
|
+
const match = file.match(/^PLAN-(\d+)\.md$/i);
|
|
100
|
+
if (match) {
|
|
101
|
+
const num = parseInt(match[1], 10);
|
|
102
|
+
if (num > maxNum) maxNum = num;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return `PLAN-${String(maxNum + 1).padStart(3, '0')}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Создаёт план из шаблона.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} templatePath — полный путь к файлу шаблона
|
|
114
|
+
* @param {object} templateFm — frontmatter шаблона
|
|
115
|
+
* @param {string} templateBody — тело шаблона
|
|
116
|
+
* @param {string} planId — ID нового плана
|
|
117
|
+
* @param {string} todayStr — сегодняшняя дата ISO
|
|
118
|
+
* @returns {string} путь к созданному плану
|
|
119
|
+
*/
|
|
120
|
+
function createPlanFromTemplate(templatePath, templateFm, templateBody, planId, todayStr) {
|
|
121
|
+
const planFm = {
|
|
122
|
+
id: planId,
|
|
123
|
+
title: `${templateFm.title} (${todayStr})`,
|
|
124
|
+
status: 'approved',
|
|
125
|
+
source_template: templateFm.id,
|
|
126
|
+
author: templateFm.author || 'system',
|
|
127
|
+
created_at: todayStr,
|
|
128
|
+
updated_at: todayStr,
|
|
129
|
+
completed_at: '',
|
|
130
|
+
previous_plan: '',
|
|
131
|
+
related_reports: []
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const planContent = serializeFrontmatter(planFm) + '\n' + templateBody;
|
|
135
|
+
const planFileName = `${planId}.md`;
|
|
136
|
+
const planPath = path.join(PLANS_DIR, planFileName);
|
|
137
|
+
|
|
138
|
+
if (!fs.existsSync(PLANS_DIR)) {
|
|
139
|
+
fs.mkdirSync(PLANS_DIR, { recursive: true });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
fs.writeFileSync(planPath, planContent, 'utf8');
|
|
143
|
+
console.log(`[INFO] Created plan ${planId} from template ${templateFm.id}`);
|
|
144
|
+
|
|
145
|
+
return planPath;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Обновляет last_triggered в шаблоне.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} templatePath — полный путь к файлу шаблона
|
|
152
|
+
* @param {object} frontmatter — frontmatter шаблона
|
|
153
|
+
* @param {string} body — тело шаблона
|
|
154
|
+
* @param {string} todayStr — сегодняшняя дата ISO
|
|
155
|
+
*/
|
|
156
|
+
function updateTemplateLastTriggered(templatePath, frontmatter, body, todayStr) {
|
|
157
|
+
frontmatter.last_triggered = todayStr;
|
|
158
|
+
const content = serializeFrontmatter(frontmatter) + '\n' + body;
|
|
159
|
+
fs.writeFileSync(templatePath, content, 'utf8');
|
|
160
|
+
console.log(`[INFO] Updated last_triggered for ${frontmatter.id} to ${todayStr}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Проверяет, есть ли незавершённые тикеты по планам, созданным из данного шаблона.
|
|
165
|
+
* Тикет считается незавершённым, если он находится не в done/.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} templateId — ID шаблона (например, "TMPL-001")
|
|
168
|
+
* @returns {boolean} true если есть активные тикеты
|
|
169
|
+
*/
|
|
170
|
+
export function hasActiveTicketsForTemplate(templateId) {
|
|
171
|
+
// 1. Найти все планы с source_template === templateId
|
|
172
|
+
const planPaths = [];
|
|
173
|
+
if (fs.existsSync(PLANS_DIR)) {
|
|
174
|
+
for (const file of fs.readdirSync(PLANS_DIR).filter(f => f.endsWith('.md'))) {
|
|
175
|
+
const content = fs.readFileSync(path.join(PLANS_DIR, file), 'utf8');
|
|
176
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
177
|
+
if (frontmatter.source_template === templateId) {
|
|
178
|
+
planPaths.push(`plans/current/${file}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (planPaths.length === 0) return false;
|
|
184
|
+
|
|
185
|
+
// 2. Проверить тикеты во всех папках кроме done/
|
|
186
|
+
if (!fs.existsSync(TICKETS_DIR)) return false;
|
|
187
|
+
|
|
188
|
+
const ticketDirs = fs.readdirSync(TICKETS_DIR, { withFileTypes: true })
|
|
189
|
+
.filter(d => d.isDirectory() && d.name !== DONE_DIR)
|
|
190
|
+
.map(d => d.name);
|
|
191
|
+
|
|
192
|
+
for (const dir of ticketDirs) {
|
|
193
|
+
const dirPath = path.join(TICKETS_DIR, dir);
|
|
194
|
+
const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.md'));
|
|
195
|
+
|
|
196
|
+
for (const file of files) {
|
|
197
|
+
const content = fs.readFileSync(path.join(dirPath, file), 'utf8');
|
|
198
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
199
|
+
if (frontmatter.parent_plan && planPaths.includes(frontmatter.parent_plan)) {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function main() {
|
|
209
|
+
if (!fs.existsSync(TEMPLATES_DIR)) {
|
|
210
|
+
console.log('[INFO] Templates directory does not exist, nothing to check');
|
|
211
|
+
printResult({ status: 'no_triggers' });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const templateFiles = fs.readdirSync(TEMPLATES_DIR)
|
|
216
|
+
.filter(f => f.endsWith('.md'));
|
|
217
|
+
|
|
218
|
+
if (templateFiles.length === 0) {
|
|
219
|
+
console.log('[INFO] No template files found');
|
|
220
|
+
printResult({ status: 'no_triggers' });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
console.log(`[INFO] Found ${templateFiles.length} template(s) in plans/templates/`);
|
|
225
|
+
|
|
226
|
+
const now = new Date();
|
|
227
|
+
const todayStr = now.toISOString().slice(0, 10);
|
|
228
|
+
const createdPlanIds = [];
|
|
229
|
+
|
|
230
|
+
for (const file of templateFiles) {
|
|
231
|
+
const templatePath = path.join(TEMPLATES_DIR, file);
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const content = fs.readFileSync(templatePath, 'utf8');
|
|
235
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
236
|
+
|
|
237
|
+
if (frontmatter.type !== 'template') {
|
|
238
|
+
console.log(`[INFO] Skipping ${file} — type is not "template"`);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!frontmatter.enabled) {
|
|
243
|
+
console.log(`[INFO] Skipping ${file} — disabled`);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!frontmatter.trigger) {
|
|
248
|
+
console.log(`[WARN] Skipping ${file} — no trigger defined`);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const shouldTrigger = evaluateTrigger(frontmatter.trigger, frontmatter.last_triggered, now);
|
|
253
|
+
|
|
254
|
+
if (!shouldTrigger) {
|
|
255
|
+
console.log(`[INFO] Template ${frontmatter.id}: trigger not fired`);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
console.log(`[INFO] Template ${frontmatter.id}: trigger fired!`);
|
|
260
|
+
|
|
261
|
+
if (hasActiveTicketsForTemplate(frontmatter.id)) {
|
|
262
|
+
console.log(`[INFO] Template ${frontmatter.id}: skipped — active tickets exist from previous plan`);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const planId = generateNextPlanId(PLANS_DIR);
|
|
267
|
+
createPlanFromTemplate(templatePath, frontmatter, body, planId, todayStr);
|
|
268
|
+
updateTemplateLastTriggered(templatePath, frontmatter, body, todayStr);
|
|
269
|
+
createdPlanIds.push(planId);
|
|
270
|
+
|
|
271
|
+
} catch (e) {
|
|
272
|
+
console.error(`[WARN] Failed to process template ${file}: ${e.message}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (createdPlanIds.length > 0) {
|
|
277
|
+
console.log(`[INFO] Created ${createdPlanIds.length} plan(s): ${createdPlanIds.join(', ')}`);
|
|
278
|
+
printResult({ status: 'plan_created', plan_ids: createdPlanIds.join(', ') });
|
|
279
|
+
} else {
|
|
280
|
+
console.log('[INFO] No triggers fired');
|
|
281
|
+
printResult({ status: 'no_triggers' });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Запуск main() только при прямом вызове (не при импорте)
|
|
286
|
+
const isDirectRun = process.argv[1] && (
|
|
287
|
+
process.argv[1].endsWith('check-plan-templates.js') ||
|
|
288
|
+
process.argv[1].endsWith('check-plan-templates')
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
if (isDirectRun) {
|
|
292
|
+
main().catch(e => {
|
|
293
|
+
console.error(`[ERROR] ${e.message}`);
|
|
294
|
+
printResult({ status: 'error', error: e.message });
|
|
295
|
+
process.exit(1);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
@@ -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
|
+
});
|