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,217 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * check-plan-decomposed.js — Проверяет состояние декомпозиции планов.
5
+ *
6
+ * Логика:
7
+ * A) Если plan_id задан — проверяет только этот план
8
+ * B) Если plan_id НЕ задан — сканирует все планы в plans/current/
9
+ *
10
+ * Для каждого плана в режиме B учитывается его status:
11
+ * - draft / completed / archived → skip
12
+ * - approved + нет тикетов → needs_decomposition (→ decompose-plan)
13
+ * - approved + есть тикеты → awaiting_atomicity (→ verify-atomicity):
14
+ * тикеты созданы, но атомарность не подтверждена
15
+ * (status плана ещё не активирован).
16
+ * - active + есть тикеты → decomposed (→ check-conditions)
17
+ * - active + нет тикетов → аномалия, logs warn, skip
18
+ *
19
+ * Результат:
20
+ * - needs_decomposition + plan_file — найден approved-план без тикетов
21
+ * - awaiting_atomicity + plan_file — найден approved-план с тикетами (ждёт verify-atomicity)
22
+ * - decomposed — все активные планы имеют тикеты, штатный поток
23
+ * - no_plan — нет планов в plans/current/
24
+ *
25
+ * Использование:
26
+ * node check-plan-decomposed.js "plan_id: PLAN-007"
27
+ * node check-plan-decomposed.js
28
+ */
29
+
30
+ import fs from 'fs';
31
+ import path from 'path';
32
+ import { findProjectRoot } from 'workflow-ai/lib/find-root.mjs';
33
+ import { parseFrontmatter, printResult, normalizePlanId, extractPlanId } from 'workflow-ai/lib/utils.mjs';
34
+
35
+ const PROJECT_DIR = findProjectRoot();
36
+ const WORKFLOW_DIR = path.join(PROJECT_DIR, '.workflow');
37
+ const TICKETS_DIR = path.join(WORKFLOW_DIR, 'tickets');
38
+ const PLANS_DIR = path.join(WORKFLOW_DIR, 'plans', 'current');
39
+
40
+ const TICKET_DIRS = ['backlog', 'ready', 'in-progress', 'review', 'done', 'blocked'];
41
+
42
+ /**
43
+ * Читает статус плана из frontmatter.
44
+ * Возвращает актуальный статус (draft / approved / active / completed / archived)
45
+ * или null при ошибке. Pipeline различает approved (ожидает декомпозиции или
46
+ * атомарности) и active (прошёл атомарность, в работе).
47
+ */
48
+ function getPlanStatus(planFile) {
49
+ const fullPath = path.join(WORKFLOW_DIR, planFile);
50
+ if (!fs.existsSync(fullPath)) return null;
51
+ try {
52
+ const content = fs.readFileSync(fullPath, 'utf8');
53
+ const { frontmatter } = parseFrontmatter(content);
54
+ return frontmatter.status || null;
55
+ } catch (e) {
56
+ console.error(`[WARN] Failed to read plan status from ${planFile}: ${e.message}`);
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Проверяет, есть ли тикеты, привязанные к данному плану
63
+ */
64
+ function hasTicketsForPlan(planId) {
65
+ for (const dir of TICKET_DIRS) {
66
+ const dirPath = path.join(TICKETS_DIR, dir);
67
+ if (!fs.existsSync(dirPath)) continue;
68
+
69
+ const files = fs.readdirSync(dirPath)
70
+ .filter(f => f.endsWith('.md') && f !== '.gitkeep.md');
71
+
72
+ for (const file of files) {
73
+ try {
74
+ const content = fs.readFileSync(path.join(dirPath, file), 'utf8');
75
+ const { frontmatter } = parseFrontmatter(content);
76
+ if (normalizePlanId(frontmatter.parent_plan) === planId) {
77
+ return true;
78
+ }
79
+ } catch (e) {
80
+ console.error(`[WARN] Failed to read ${dir}/${file}: ${e.message}`);
81
+ }
82
+ }
83
+ }
84
+ return false;
85
+ }
86
+
87
+ /**
88
+ * Находит файл плана в plans/current/
89
+ */
90
+ function findPlanFile(planId) {
91
+ if (!fs.existsSync(PLANS_DIR)) return null;
92
+
93
+ const expectedName = `${planId}.md`;
94
+ const filePath = path.join(PLANS_DIR, expectedName);
95
+ if (fs.existsSync(filePath)) {
96
+ return `plans/current/${expectedName}`;
97
+ }
98
+
99
+ // Поиск по всем файлам на случай другого именования
100
+ const files = fs.readdirSync(PLANS_DIR).filter(f => f.endsWith('.md'));
101
+ for (const file of files) {
102
+ if (normalizePlanId(file) === planId) {
103
+ return `plans/current/${file}`;
104
+ }
105
+ }
106
+
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Возвращает все файлы планов из plans/current/
112
+ */
113
+ function getAllPlanFiles() {
114
+ if (!fs.existsSync(PLANS_DIR)) return [];
115
+
116
+ return fs.readdirSync(PLANS_DIR)
117
+ .filter(f => f.endsWith('.md'))
118
+ .map(f => ({
119
+ planId: normalizePlanId(f),
120
+ planFile: `plans/current/${f}`
121
+ }))
122
+ .filter(p => p.planId !== null);
123
+ }
124
+
125
+ async function main() {
126
+ const planId = extractPlanId();
127
+
128
+ if (planId) {
129
+ // Режим A: конкретный план
130
+ console.log(`[INFO] Checking decomposition for plan: ${planId}`);
131
+
132
+ const planFile = findPlanFile(planId);
133
+ if (!planFile) {
134
+ console.log(`[INFO] Plan ${planId} not found in plans/current/`);
135
+ printResult({ status: 'no_plan' });
136
+ return;
137
+ }
138
+
139
+ console.log(`[INFO] Found plan file: ${planFile}`);
140
+
141
+ const planStatus = getPlanStatus(planFile);
142
+ const hasTickets = hasTicketsForPlan(planId);
143
+
144
+ if (hasTickets && planStatus === 'active') {
145
+ console.log(`[INFO] Plan ${planId} is active and has tickets — decomposed`);
146
+ printResult({ status: 'decomposed' });
147
+ return;
148
+ }
149
+
150
+ if (hasTickets && planStatus === 'approved') {
151
+ console.log(`[INFO] Plan ${planId} is approved and has tickets — awaiting atomicity verification`);
152
+ printResult({ status: 'awaiting_atomicity', plan_file: planFile });
153
+ return;
154
+ }
155
+
156
+ if (!hasTickets) {
157
+ console.log(`[INFO] Plan ${planId} has no tickets — needs decomposition`);
158
+ printResult({ status: 'needs_decomposition', plan_file: planFile });
159
+ return;
160
+ }
161
+
162
+ console.log(`[INFO] Plan ${planId} has tickets but status="${planStatus}" — treating as decomposed`);
163
+ printResult({ status: 'decomposed' });
164
+ return;
165
+ }
166
+
167
+ // Режим B: сканируем все планы в plans/current/
168
+ console.log('[INFO] No plan_id specified, scanning all plans in plans/current/');
169
+
170
+ const allPlans = getAllPlanFiles();
171
+ if (allPlans.length === 0) {
172
+ console.log('[INFO] No plans found in plans/current/');
173
+ printResult({ status: 'no_plan' });
174
+ return;
175
+ }
176
+
177
+ console.log(`[INFO] Found ${allPlans.length} plan(s) in plans/current/`);
178
+
179
+ for (const { planId: pid, planFile } of allPlans) {
180
+ const planStatus = getPlanStatus(planFile);
181
+
182
+ if (planStatus !== 'approved' && planStatus !== 'active') {
183
+ console.log(`[INFO] Plan ${pid} has status "${planStatus}" — skipping`);
184
+ continue;
185
+ }
186
+
187
+ const hasTickets = hasTicketsForPlan(pid);
188
+
189
+ if (planStatus === 'approved' && !hasTickets) {
190
+ console.log(`[INFO] Plan ${pid} is approved with no tickets — needs decomposition`);
191
+ printResult({ status: 'needs_decomposition', plan_file: planFile });
192
+ return;
193
+ }
194
+
195
+ if (planStatus === 'approved' && hasTickets) {
196
+ console.log(`[INFO] Plan ${pid} is approved and has tickets — awaiting atomicity verification`);
197
+ printResult({ status: 'awaiting_atomicity', plan_file: planFile });
198
+ return;
199
+ }
200
+
201
+ if (planStatus === 'active' && !hasTickets) {
202
+ console.log(`[WARN] Plan ${pid} is active but has no tickets — anomaly, skipping`);
203
+ continue;
204
+ }
205
+
206
+ console.log(`[INFO] Plan ${pid} is active and has tickets — decomposed`);
207
+ }
208
+
209
+ console.log('[INFO] All eligible plans are decomposed');
210
+ printResult({ status: 'decomposed' });
211
+ }
212
+
213
+ main().catch(e => {
214
+ console.error(`[ERROR] ${e.message}`);
215
+ printResult({ status: 'error', error: e.message });
216
+ process.exit(1);
217
+ });
@@ -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
+ }