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.
@@ -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
+ })();
@@ -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
+ });