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,258 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * check-conditions.js — Проверяет условия тикетов в backlog/ и выводит список готовых
5
+ *
6
+ * Использование:
7
+ * node check-conditions.js
8
+ *
9
+ * Выводит результат в формате:
10
+ * ---RESULT---
11
+ * status: has_ready
12
+ * ready_tickets: IMPL-002, DOCS-001
13
+ * ---RESULT---
14
+ *
15
+ * или если готовых нет, но есть тикеты в ready/:
16
+ * ---RESULT---
17
+ * status: default
18
+ * ready_tickets:
19
+ * ---RESULT---
20
+ *
21
+ * или если backlog пуст и нет тикетов в ready/:
22
+ * ---RESULT---
23
+ * status: empty
24
+ * ready_tickets:
25
+ * ---RESULT---
26
+ */
27
+
28
+ import fs from 'fs';
29
+ import path from 'path';
30
+ import { findProjectRoot } from 'workflow-ai/lib/find-root.mjs';
31
+ import { parseFrontmatter, printResult, normalizePlanId, extractPlanId, serializeFrontmatter } from 'workflow-ai/lib/utils.mjs';
32
+
33
+ const PROJECT_DIR = findProjectRoot();
34
+ const WORKFLOW_DIR = path.join(PROJECT_DIR, '.workflow');
35
+ const TICKETS_DIR = path.join(WORKFLOW_DIR, 'tickets');
36
+ const BACKLOG_DIR = path.join(TICKETS_DIR, 'backlog');
37
+ const READY_DIR = path.join(TICKETS_DIR, 'ready');
38
+ const DONE_DIR = path.join(TICKETS_DIR, 'done');
39
+ const ARCHIVE_DIR = path.join(TICKETS_DIR, 'archive');
40
+
41
+ /**
42
+ * Проверяет одно условие тикета
43
+ */
44
+ function checkCondition(condition) {
45
+ const { type, value } = condition;
46
+
47
+ switch (type) {
48
+ case 'file_exists':
49
+ return fs.existsSync(path.join(PROJECT_DIR, value));
50
+
51
+ case 'file_not_exists':
52
+ return !fs.existsSync(path.join(PROJECT_DIR, value));
53
+
54
+ case 'tasks_completed': {
55
+ if (!value || (Array.isArray(value) && value.length === 0)) return true;
56
+ const ids = Array.isArray(value) ? value : [value];
57
+ return ids.every(taskId =>
58
+ fs.existsSync(path.join(DONE_DIR, `${taskId}.md`)) ||
59
+ fs.existsSync(path.join(ARCHIVE_DIR, `${taskId}.md`))
60
+ );
61
+ }
62
+
63
+ case 'date_after':
64
+ return new Date() > new Date(value);
65
+
66
+ case 'date_before':
67
+ return new Date() < new Date(value);
68
+
69
+ case 'manual_approval':
70
+ return false;
71
+
72
+ default:
73
+ console.error(`[WARN] Unknown condition type: ${type}`);
74
+ return true;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Проверяет зависимости тикета
80
+ */
81
+ function checkDependencies(dependencies) {
82
+ if (!dependencies || dependencies.length === 0) return true;
83
+ return dependencies.every(depId =>
84
+ fs.existsSync(path.join(DONE_DIR, `${depId}.md`)) ||
85
+ fs.existsSync(path.join(ARCHIVE_DIR, `${depId}.md`))
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Считывает все тикеты из директории
91
+ */
92
+ function readTickets(dir) {
93
+ if (!fs.existsSync(dir)) return [];
94
+
95
+ const tickets = [];
96
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.md') && f !== '.gitkeep.md');
97
+
98
+ for (const file of files) {
99
+ const filePath = path.join(dir, file);
100
+ try {
101
+ const content = fs.readFileSync(filePath, 'utf8');
102
+ const { frontmatter } = parseFrontmatter(content);
103
+ tickets.push({ id: frontmatter.id || file.replace('.md', ''), frontmatter });
104
+ } catch (e) {
105
+ console.error(`[WARN] Failed to read ticket ${file}: ${e.message}`);
106
+ }
107
+ }
108
+
109
+ return tickets;
110
+ }
111
+
112
+ /**
113
+ * Перемещает тикет из ready/ в backlog/
114
+ */
115
+ function demoteToBacklog(ticketId) {
116
+ const sourcePath = path.join(READY_DIR, `${ticketId}.md`);
117
+ const targetPath = path.join(BACKLOG_DIR, `${ticketId}.md`);
118
+
119
+ if (!fs.existsSync(sourcePath)) {
120
+ console.error(`[WARN] ${ticketId}: not found in ready/, skipping`);
121
+ return false;
122
+ }
123
+
124
+ const content = fs.readFileSync(sourcePath, 'utf8');
125
+ const { frontmatter, body } = parseFrontmatter(content);
126
+
127
+ frontmatter.updated_at = new Date().toISOString();
128
+
129
+ const newContent = serializeFrontmatter(frontmatter) + body;
130
+
131
+ if (!fs.existsSync(BACKLOG_DIR)) {
132
+ fs.mkdirSync(BACKLOG_DIR, { recursive: true });
133
+ }
134
+
135
+ fs.renameSync(sourcePath, targetPath);
136
+ fs.writeFileSync(targetPath, newContent, 'utf8');
137
+ return true;
138
+ }
139
+
140
+ /**
141
+ * Проверяет все тикеты в backlog/ и возвращает список готовых
142
+ */
143
+ function checkBacklog(planId) {
144
+ const allTickets = readTickets(BACKLOG_DIR);
145
+ const tickets = planId
146
+ ? allTickets.filter(t => normalizePlanId(t.frontmatter.parent_plan) === planId)
147
+ : allTickets;
148
+
149
+ const ready = [];
150
+ const waiting = [];
151
+
152
+ for (const ticket of tickets) {
153
+ const { frontmatter, id } = ticket;
154
+
155
+ const conditions = frontmatter.conditions || [];
156
+ const dependencies = frontmatter.dependencies || [];
157
+
158
+ const depsMet = checkDependencies(dependencies);
159
+ const conditionsMet = conditions.every(checkCondition);
160
+
161
+ if (depsMet && conditionsMet) {
162
+ ready.push(id);
163
+ if (frontmatter.type === 'human') {
164
+ console.log(`[INFO] ${id}: type is 'human', moved to ready/ (requires manual execution)`);
165
+ }
166
+ } else {
167
+ const reasons = [];
168
+ if (!depsMet) reasons.push(`ждёт зависимости: ${dependencies.join(', ')}`);
169
+ conditions.forEach(c => {
170
+ if (!checkCondition(c)) reasons.push(`условие не выполнено: ${c.type}`);
171
+ });
172
+ waiting.push({ id, reasons });
173
+ }
174
+ }
175
+
176
+ return { ready, waiting, total: tickets.length };
177
+ }
178
+
179
+ /**
180
+ * Проверяет тикеты в ready/ и возвращает тикеты в backlog при невыполненных условиях
181
+ */
182
+ function checkReady(planId) {
183
+ const allTickets = readTickets(READY_DIR);
184
+ const tickets = planId
185
+ ? allTickets.filter(t => normalizePlanId(t.frontmatter.parent_plan) === planId)
186
+ : allTickets;
187
+
188
+ const demoted = [];
189
+
190
+ for (const ticket of tickets) {
191
+ const { frontmatter, id } = ticket;
192
+ const conditions = frontmatter.conditions || [];
193
+ const dependencies = frontmatter.dependencies || [];
194
+
195
+ const depsMet = checkDependencies(dependencies);
196
+ const conditionsMet = conditions.every(checkCondition);
197
+
198
+ if (!depsMet || !conditionsMet) {
199
+ if (demoteToBacklog(id)) {
200
+ console.log(`[INFO] ${id}: ready/ → backlog/ (условия не выполнены)`);
201
+ demoted.push(id);
202
+ }
203
+ }
204
+ }
205
+
206
+ return { demoted, total: tickets.length };
207
+ }
208
+
209
+ async function main() {
210
+ const planId = extractPlanId();
211
+
212
+ if (planId) {
213
+ console.log(`[INFO] Filtering by plan_id: ${planId}`);
214
+ }
215
+
216
+ // Сначала демотирование невалидных тикетов из ready/
217
+ console.log(`[INFO] Checking ready/ for invalid tickets: ${READY_DIR}`);
218
+ const { demoted, total: readyTotal } = checkReady(planId);
219
+ console.log(`[INFO] Total in ready/${planId ? ` (plan ${planId})` : ''}: ${readyTotal}`);
220
+ console.log(`[INFO] Demoted to backlog: ${demoted.length}`);
221
+
222
+ // Затем проверка backlog — демотированные тикеты сразу переоцениваются
223
+ console.log(`[INFO] Scanning backlog/: ${BACKLOG_DIR}`);
224
+
225
+ const { ready, waiting, total } = checkBacklog(planId);
226
+
227
+ console.log(`[INFO] Total in backlog${planId ? ` (plan ${planId})` : ''}: ${total}`);
228
+ console.log(`[INFO] Ready: ${ready.length}, Waiting: ${waiting.length}`);
229
+
230
+ if (ready.length > 0) {
231
+ console.log(`[INFO] Ready tickets: ${ready.join(', ')}`);
232
+ }
233
+
234
+ for (const { id, reasons } of waiting) {
235
+ console.log(`[INFO] ${id}: ${reasons.join('; ')}`);
236
+ }
237
+
238
+ if (ready.length > 0) {
239
+ printResult({ status: 'has_ready', ready_tickets: ready.join(', '), demoted_tickets: demoted.join(', ') });
240
+ return;
241
+ }
242
+
243
+ // Нет готовых — проверяем есть ли что-то в ready/
244
+ const readyDirTickets = readTickets(READY_DIR);
245
+ if (readyDirTickets.length > 0) {
246
+ console.log(`[INFO] No new ready tickets, but ready/ has ${readyDirTickets.length} ticket(s)`);
247
+ printResult({ status: 'default', ready_tickets: '', demoted_tickets: demoted.join(', ') });
248
+ } else {
249
+ console.log('[INFO] No ready tickets and ready/ is empty');
250
+ printResult({ status: 'empty', ready_tickets: '', demoted_tickets: demoted.join(', ') });
251
+ }
252
+ }
253
+
254
+ main().catch(e => {
255
+ console.error(`[ERROR] ${e.message}`);
256
+ printResult({ status: 'error', error: e.message });
257
+ process.exit(1);
258
+ });
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * check-mcp.js — Проверяет доступность MCP-серверов из .mcp.json.
5
+ *
6
+ * Используется как stage-скрипт в pipeline: запускается перед execute-task для
7
+ * тикетов определённых типов (см. require_for ниже). Если MCP-серверы недоступны,
8
+ * stage возвращает status: fail и runner может пропустить выполнение задачи или
9
+ * пометить её как заблокированную.
10
+ *
11
+ * Что делает:
12
+ * 1. Парсит .mcp.json — список ожидаемых серверов.
13
+ * 2. Сверяет с .claude/settings.local.json (enabledMcpjsonServers / enableAllProjectMcpServers).
14
+ * 3. Для http(s)-эндпоинтов делает GET-пинг и сообщает reachability.
15
+ * 4. Для stdio-серверов проверяет, что бинарь резолвится в PATH.
16
+ *
17
+ * ВАЖНО: «может ли сервер быть доступен» (эндпоинт жив + harness разрешает) ≠
18
+ * «доступен ли он прямо сейчас в чате». Последнее знает только harness в момент
19
+ * старта сессии. Скрипт отвечает на первый вопрос.
20
+ *
21
+ * Использование:
22
+ * Запускается runner'ом workflow-ai как stage-скрипт. Runner передаёт промпт
23
+ * (со встроенной секцией Context) одним аргументом — как и для check-relevance.
24
+ * Скрипт ищет в нём regexp'ами поля mcp_require_for и task_type.
25
+ *
26
+ * Для ручного запуска можно передать те же поля:
27
+ * node check-mcp.js "mcp_require_for: qa\ntask_type: qa"
28
+ *
29
+ * Поля контекста, которые скрипт читает:
30
+ * mcp_require_for — comma-separated список типов тикетов, для которых нужна
31
+ * проверка MCP. Задаётся в pipeline.yaml в секции
32
+ * context.mcp_require_for. Пустая строка = всегда skipped.
33
+ * task_type — тип текущего тикета. Задаётся runner'ом из контекста.
34
+ *
35
+ * Поведение по task_type:
36
+ * - task_type ∈ require_for → реальная проверка MCP → status: ok|fail
37
+ * - task_type ∉ require_for → пропуск → status: skipped
38
+ *
39
+ * Отсутствие .mcp.json или пустой список серверов — это ok (нечего проверять).
40
+ * Fail — только когда настроенные серверы недоступны или не разрешены.
41
+ *
42
+ * Вывод (для runner'а):
43
+ * ---RESULT---
44
+ * status: ok|skipped|fail
45
+ * reason: <короткое описание>
46
+ * ---RESULT---
47
+ *
48
+ * Exit code:
49
+ * 0 — всегда (статус решает goto в pipeline.yaml).
50
+ * Runner workflow-ai при exitCode != 0 переписывает наш status на "failed",
51
+ * что ломает маршрутизацию. Поэтому валим логический результат через status,
52
+ * а не через exit code.
53
+ */
54
+
55
+ import fs from 'fs';
56
+ import path from 'path';
57
+ import http from 'http';
58
+ import https from 'https';
59
+ import { execSync } from 'child_process';
60
+
61
+ const projectRoot = process.cwd();
62
+ const mcpConfigPath = path.join(projectRoot, '.mcp.json');
63
+ const settingsPath = path.join(projectRoot, '.claude', 'settings.local.json');
64
+
65
+ /**
66
+ * Извлекает поле из промпта, который runner передаёт скрипту.
67
+ * Промпт — это многострочный текст с секцией "Context:" внутри:
68
+ *
69
+ * check-mcp
70
+ *
71
+ * Context:
72
+ * mcp_require_for: qa
73
+ * task_type: qa
74
+ * ticket_id: QA-001
75
+ *
76
+ * Промпт может прилететь как один аргумент (process.argv[2]) или собран из
77
+ * нескольких — поэтому склеиваем все argv в одну строку и ищем regexp.
78
+ */
79
+ function extractField(text, field) {
80
+ const re = new RegExp(`(?:^|\\n)\\s*${field}\\s*:\\s*([^\\n]*)`, 'i');
81
+ const m = text.match(re);
82
+ return m ? m[1].trim() : '';
83
+ }
84
+
85
+ function emitResult(status, reason) {
86
+ console.log('---RESULT---');
87
+ console.log(`status: ${status}`);
88
+ if (reason) console.log(`reason: ${reason}`);
89
+ console.log('---RESULT---');
90
+ }
91
+
92
+ function readJson(p) {
93
+ if (!fs.existsSync(p)) return { missing: true };
94
+ try {
95
+ return { data: JSON.parse(fs.readFileSync(p, 'utf8')) };
96
+ } catch (e) {
97
+ return { error: e.message };
98
+ }
99
+ }
100
+
101
+ function findHttpUrl(args) {
102
+ if (!Array.isArray(args)) return null;
103
+ return args.find((a) => typeof a === 'string' && /^https?:\/\//.test(a)) || null;
104
+ }
105
+
106
+ function pingHttp(url, timeoutMs = 3000) {
107
+ return new Promise((resolve) => {
108
+ let settled = false;
109
+ const finish = (result) => {
110
+ if (settled) return;
111
+ settled = true;
112
+ resolve(result);
113
+ };
114
+
115
+ let parsed;
116
+ try {
117
+ parsed = new URL(url);
118
+ } catch {
119
+ return finish({ ok: false, reason: 'invalid url' });
120
+ }
121
+
122
+ const lib = parsed.protocol === 'https:' ? https : http;
123
+ const req = lib.request(
124
+ {
125
+ method: 'GET',
126
+ hostname: parsed.hostname,
127
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
128
+ path: parsed.pathname + parsed.search,
129
+ timeout: timeoutMs,
130
+ },
131
+ (res) => {
132
+ finish({ ok: true, status: res.statusCode });
133
+ res.resume();
134
+ }
135
+ );
136
+
137
+ req.on('timeout', () => {
138
+ req.destroy();
139
+ finish({ ok: false, reason: `timeout ${timeoutMs}ms` });
140
+ });
141
+ req.on('error', (err) => finish({ ok: false, reason: err.code || err.message }));
142
+ req.end();
143
+ });
144
+ }
145
+
146
+ function commandExists(cmd) {
147
+ if (!cmd) return false;
148
+ const probe = process.platform === 'win32' ? `where ${cmd}` : `command -v ${cmd}`;
149
+ try {
150
+ execSync(probe, { stdio: 'ignore' });
151
+ return true;
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ function isEnabled(name, settings) {
158
+ if (!settings) return { enabled: true, source: 'no settings file' };
159
+ if (settings.enableAllProjectMcpServers === true) {
160
+ return { enabled: true, source: 'enableAllProjectMcpServers' };
161
+ }
162
+ const list = settings.enabledMcpjsonServers;
163
+ if (Array.isArray(list) && list.includes(name)) {
164
+ return { enabled: true, source: 'enabledMcpjsonServers' };
165
+ }
166
+ return { enabled: false, source: 'not in enabledMcpjsonServers' };
167
+ }
168
+
169
+ async function checkServer(name, cfg, settings) {
170
+ const enabled = isEnabled(name, settings);
171
+ const httpUrl = findHttpUrl(cfg.args);
172
+ const result = {
173
+ name,
174
+ enabled: enabled.enabled,
175
+ enabledSource: enabled.source,
176
+ transport: httpUrl ? 'http' : 'stdio',
177
+ command: cfg.command,
178
+ url: httpUrl,
179
+ };
180
+
181
+ if (httpUrl) {
182
+ const ping = await pingHttp(httpUrl);
183
+ result.reachable = ping.ok;
184
+ result.detail = ping.ok ? `HTTP ${ping.status}` : ping.reason;
185
+ } else {
186
+ const ok = commandExists(cfg.command);
187
+ result.reachable = ok;
188
+ result.detail = ok ? 'command found in PATH' : 'command not in PATH';
189
+ }
190
+ return result;
191
+ }
192
+
193
+ function formatRow(r) {
194
+ const flag = r.enabled && r.reachable ? 'OK ' : 'FAIL';
195
+ const enabled = r.enabled ? 'enabled' : 'disabled';
196
+ const target = r.url || r.command;
197
+ return ` [${flag}] ${r.name.padEnd(20)} ${enabled.padEnd(9)} ${r.transport.padEnd(6)} ${target}\n → ${r.detail} (${r.enabledSource})`;
198
+ }
199
+
200
+ (async () => {
201
+ // Runner передаёт промпт (с секцией Context) одним или несколькими аргументами.
202
+ // Склеиваем всё в одну строку для извлечения полей.
203
+ const promptText = process.argv.slice(2).join('\n');
204
+ const taskType = extractField(promptText, 'task_type');
205
+ const requireForRaw = extractField(promptText, 'mcp_require_for');
206
+ const requireFor = new Set(
207
+ requireForRaw
208
+ .split(',')
209
+ .map((s) => s.trim())
210
+ .filter(Boolean)
211
+ );
212
+
213
+ // Skip-ветка: список пуст (mcp_require_for не задан в context) или task_type не входит.
214
+ if (requireFor.size === 0) {
215
+ console.log(
216
+ '[check-mcp] mcp_require_for пуст — проверка отключена. Задайте список типов в pipeline.yaml (context.mcp_require_for).'
217
+ );
218
+ emitResult('skipped', 'mcp_require_for is empty');
219
+ process.exit(0);
220
+ }
221
+ if (!requireFor.has(taskType)) {
222
+ console.log(
223
+ `[check-mcp] task_type="${taskType}" не входит в mcp_require_for=[${[...requireFor].join(', ')}]. Пропуск.`
224
+ );
225
+ emitResult('skipped', `task_type=${taskType || 'unknown'} not in mcp_require_for`);
226
+ process.exit(0);
227
+ }
228
+
229
+ // Реальная проверка.
230
+ const mcpRead = readJson(mcpConfigPath);
231
+ if (mcpRead.missing) {
232
+ console.log('[check-mcp] .mcp.json отсутствует — нет серверов для проверки.');
233
+ emitResult('ok', 'no mcp.json — nothing to check');
234
+ process.exit(0);
235
+ }
236
+ if (mcpRead.error) {
237
+ console.error(`[check-mcp] не удалось распарсить .mcp.json: ${mcpRead.error}`);
238
+ emitResult('fail', `mcp.json parse error: ${mcpRead.error}`);
239
+ process.exit(0);
240
+ }
241
+ const servers = mcpRead.data && mcpRead.data.mcpServers;
242
+ if (!servers || Object.keys(servers).length === 0) {
243
+ console.log('[check-mcp] .mcp.json не содержит серверов — нечего проверять.');
244
+ emitResult('ok', 'no mcp servers — nothing to check');
245
+ process.exit(0);
246
+ }
247
+
248
+ const settings = readJson(settingsPath).data || null;
249
+ const names = Object.keys(servers);
250
+
251
+ console.log(`[check-mcp] task_type=${taskType}, проверяю ${names.length} MCP-серверов:\n`);
252
+
253
+ const results = await Promise.all(
254
+ names.map((n) => checkServer(n, servers[n], settings))
255
+ );
256
+ for (const r of results) console.log(formatRow(r));
257
+
258
+ const failed = results.filter((r) => !r.enabled || !r.reachable);
259
+ console.log('');
260
+
261
+ if (failed.length === 0) {
262
+ console.log(`[check-mcp] Все ${results.length} серверов OK`);
263
+ emitResult('ok', `${results.length} servers reachable`);
264
+ process.exit(0);
265
+ }
266
+
267
+ console.log(`[check-mcp] Проблемы: ${failed.length}/${results.length}`);
268
+ for (const f of failed) {
269
+ const reasons = [];
270
+ if (!f.enabled) reasons.push(`не разрешён (${f.enabledSource})`);
271
+ if (!f.reachable) reasons.push(f.detail);
272
+ console.log(` - ${f.name}: ${reasons.join('; ')}`);
273
+ }
274
+ const failNames = failed.map((f) => f.name).join(', ');
275
+ emitResult('fail', `unavailable: ${failNames}`);
276
+ process.exit(0);
277
+ })();