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.
- package/README.md +61 -0
- package/agent-templates/CLAUDE.md.tpl +60 -58
- package/agent-templates/QWEN.md.tpl +60 -58
- package/package.json +2 -1
- package/src/init.mjs +438 -437
- package/src/lib/agent-spawner.mjs +338 -0
- package/src/runner.mjs +16 -14
- 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/get-next-test-id.js +94 -0
- package/src/scripts/migrate-backlog-to-tests.js +406 -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/src/scripts/run-skill-tests.js +1491 -0
- package/src/scripts/scan-fixtures-for-secrets.js +248 -0
- package/src/scripts/tests/timeout-cascade.test.js +28 -0
- package/templates/plan-template.md +1 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn, execSync } from 'child_process';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
const ResultParser = {
|
|
7
|
+
STATUS_ALIASES: {
|
|
8
|
+
pass: 'passed',
|
|
9
|
+
approved: 'passed',
|
|
10
|
+
success: 'passed',
|
|
11
|
+
succeeded: 'passed',
|
|
12
|
+
ok: 'passed',
|
|
13
|
+
accepted: 'passed',
|
|
14
|
+
lgtm: 'passed',
|
|
15
|
+
fixed: 'passed',
|
|
16
|
+
resolved: 'passed',
|
|
17
|
+
fail: 'failed',
|
|
18
|
+
rejected: 'failed',
|
|
19
|
+
denied: 'failed',
|
|
20
|
+
not_passed: 'failed',
|
|
21
|
+
err: 'error',
|
|
22
|
+
crash: 'error',
|
|
23
|
+
timeout: 'error',
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
normalizeStatus(status) {
|
|
27
|
+
const lower = status.toLowerCase();
|
|
28
|
+
const canonical = ResultParser.STATUS_ALIASES[lower];
|
|
29
|
+
if (canonical) {
|
|
30
|
+
return canonical;
|
|
31
|
+
}
|
|
32
|
+
return status;
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
parse(output, stageId) {
|
|
36
|
+
const marker = '---RESULT---';
|
|
37
|
+
const startIdx = output.indexOf(marker);
|
|
38
|
+
const endIdx = startIdx !== -1 ? output.indexOf(marker, startIdx + marker.length) : -1;
|
|
39
|
+
|
|
40
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
41
|
+
const resultBlock = output.substring(startIdx + marker.length, endIdx).trim();
|
|
42
|
+
const data = ResultParser.parseResultBlock(resultBlock);
|
|
43
|
+
const normalizedStatus = ResultParser.normalizeStatus(data.status || 'default');
|
|
44
|
+
return {
|
|
45
|
+
status: normalizedStatus,
|
|
46
|
+
data: data.data || {},
|
|
47
|
+
raw: output,
|
|
48
|
+
parsed: true
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return ResultParser.fallbackParse(output, stageId);
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
parseResultBlock(block) {
|
|
56
|
+
const lines = block.split('\n');
|
|
57
|
+
const data = {};
|
|
58
|
+
let status = 'default';
|
|
59
|
+
let currentKey = null;
|
|
60
|
+
let multilineValue = null;
|
|
61
|
+
|
|
62
|
+
const flushMultiline = () => {
|
|
63
|
+
if (currentKey !== null && multilineValue !== null) {
|
|
64
|
+
data[currentKey] = multilineValue.replace(/\n$/, '');
|
|
65
|
+
currentKey = null;
|
|
66
|
+
multilineValue = null;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
for (let i = 0; i < lines.length; i++) {
|
|
71
|
+
const line = lines[i];
|
|
72
|
+
const topLevelMatch = line.match(/^([^:\s][^:]*):\s*(.*)$/);
|
|
73
|
+
|
|
74
|
+
if (topLevelMatch) {
|
|
75
|
+
flushMultiline();
|
|
76
|
+
const key = topLevelMatch[1].trim();
|
|
77
|
+
const value = topLevelMatch[2].trim();
|
|
78
|
+
|
|
79
|
+
if (value !== '') {
|
|
80
|
+
if (key === 'status') {
|
|
81
|
+
status = value;
|
|
82
|
+
} else {
|
|
83
|
+
data[key] = value;
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
currentKey = key;
|
|
87
|
+
multilineValue = '';
|
|
88
|
+
}
|
|
89
|
+
} else if (currentKey !== null && (line.startsWith(' ') || line.startsWith('\t') || line === '')) {
|
|
90
|
+
multilineValue += line + '\n';
|
|
91
|
+
} else if (currentKey !== null) {
|
|
92
|
+
flushMultiline();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
flushMultiline();
|
|
97
|
+
return { status, data };
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
fallbackParse(output, stageId) {
|
|
101
|
+
const lines = output.split('\n');
|
|
102
|
+
let status = 'default';
|
|
103
|
+
const extractedData = {};
|
|
104
|
+
let inResultSection = false;
|
|
105
|
+
|
|
106
|
+
for (const line of lines) {
|
|
107
|
+
const trimmedLine = line.trim();
|
|
108
|
+
const statusMatch = trimmedLine.match(/^(?:status|Status):\s*(\w+)/i);
|
|
109
|
+
if (statusMatch) {
|
|
110
|
+
status = statusMatch[1];
|
|
111
|
+
inResultSection = true;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (inResultSection) {
|
|
116
|
+
const dataMatch = trimmedLine.match(/^(\w+):\s*(.+)$/i);
|
|
117
|
+
if (dataMatch && dataMatch[1].toLowerCase() !== 'status') {
|
|
118
|
+
extractedData[dataMatch[1]] = dataMatch[2];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (status === 'default') {
|
|
124
|
+
const lowerOutput = output.toLowerCase();
|
|
125
|
+
if (lowerOutput.includes('completed') || lowerOutput.includes('success') || lowerOutput.includes('done')) {
|
|
126
|
+
status = 'default';
|
|
127
|
+
extractedData._inferred = 'success_keywords';
|
|
128
|
+
} else if (lowerOutput.includes('error') || lowerOutput.includes('failed')) {
|
|
129
|
+
status = 'error';
|
|
130
|
+
extractedData._inferred = 'error_keywords';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const normalizedStatus = ResultParser.normalizeStatus(status);
|
|
135
|
+
return {
|
|
136
|
+
status: normalizedStatus,
|
|
137
|
+
data: extractedData,
|
|
138
|
+
raw: output,
|
|
139
|
+
parsed: false
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export async function spawnAgent(agentConfig, prompt, options = {}) {
|
|
145
|
+
const {
|
|
146
|
+
timeout = 300,
|
|
147
|
+
logger = null,
|
|
148
|
+
resultParser = ResultParser,
|
|
149
|
+
stageId = 'unknown',
|
|
150
|
+
skillId = null,
|
|
151
|
+
projectRoot = process.cwd(),
|
|
152
|
+
currentChildRef = null
|
|
153
|
+
} = options;
|
|
154
|
+
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
const args = [...agentConfig.args];
|
|
157
|
+
const finalPrompt = prompt;
|
|
158
|
+
|
|
159
|
+
const useShell = process.platform === 'win32' && agentConfig.command !== 'node';
|
|
160
|
+
const useStdin = useShell && finalPrompt.includes('\n');
|
|
161
|
+
|
|
162
|
+
if (!useStdin) {
|
|
163
|
+
args.push(finalPrompt);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (logger) {
|
|
167
|
+
const displayArgs = skillId ? [...args.slice(0, -1), skillId] : args;
|
|
168
|
+
logger.info(`RUN ${agentConfig.command} ${displayArgs.join(' ')}`, stageId);
|
|
169
|
+
const promptLines = prompt.split('\n').filter(l => l.trim());
|
|
170
|
+
if (promptLines.length > 1) {
|
|
171
|
+
for (const line of promptLines.slice(1)) {
|
|
172
|
+
logger.info(` ${line}`, stageId);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const startTime = Date.now();
|
|
178
|
+
|
|
179
|
+
const child = spawn(agentConfig.command, args, {
|
|
180
|
+
cwd: path.resolve(projectRoot, agentConfig.workdir || '.'),
|
|
181
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
182
|
+
shell: useShell
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (currentChildRef) {
|
|
186
|
+
currentChildRef.current = child;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (useStdin) {
|
|
190
|
+
child.stdin.write(finalPrompt);
|
|
191
|
+
child.stdin.end();
|
|
192
|
+
} else {
|
|
193
|
+
child.stdin.end();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let stdout = '';
|
|
197
|
+
let stderr = '';
|
|
198
|
+
let timedOut = false;
|
|
199
|
+
|
|
200
|
+
const timeoutId = setTimeout(() => {
|
|
201
|
+
timedOut = true;
|
|
202
|
+
if (process.platform === 'win32' && child.pid) {
|
|
203
|
+
try { execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: 'pipe' }); } catch {}
|
|
204
|
+
} else {
|
|
205
|
+
child.kill('SIGTERM');
|
|
206
|
+
}
|
|
207
|
+
if (logger) {
|
|
208
|
+
logger.timeout(stageId, timeout);
|
|
209
|
+
}
|
|
210
|
+
reject(new Error(`Stage "${stageId}" timed out after ${timeout}s`));
|
|
211
|
+
}, timeout * 1000);
|
|
212
|
+
|
|
213
|
+
let stdoutBuffer = '';
|
|
214
|
+
let agentText = '';
|
|
215
|
+
child.stdout.on('data', (data) => {
|
|
216
|
+
const chunk = data.toString();
|
|
217
|
+
stdout += chunk;
|
|
218
|
+
stdoutBuffer += chunk;
|
|
219
|
+
const lines = stdoutBuffer.split('\n');
|
|
220
|
+
stdoutBuffer = lines.pop();
|
|
221
|
+
for (const line of lines) {
|
|
222
|
+
if (!line.trim()) continue;
|
|
223
|
+
try {
|
|
224
|
+
const obj = JSON.parse(line);
|
|
225
|
+
if (obj.type === 'content_block_delta' && obj.delta?.text) {
|
|
226
|
+
process.stdout.write(obj.delta.text);
|
|
227
|
+
agentText += obj.delta.text;
|
|
228
|
+
} else if (obj.type === 'assistant' && obj.message?.content) {
|
|
229
|
+
for (const block of obj.message.content) {
|
|
230
|
+
if (block.type === 'text' && block.text) {
|
|
231
|
+
process.stdout.write(block.text);
|
|
232
|
+
agentText += block.text;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
} catch {
|
|
237
|
+
process.stdout.write(line + '\n');
|
|
238
|
+
agentText += line + '\n';
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
child.stderr.on('data', (data) => {
|
|
244
|
+
stderr += data.toString();
|
|
245
|
+
process.stderr.write(data);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
child.on('close', (code) => {
|
|
249
|
+
if (currentChildRef) {
|
|
250
|
+
currentChildRef.current = null;
|
|
251
|
+
}
|
|
252
|
+
clearTimeout(timeoutId);
|
|
253
|
+
const durationMs = Date.now() - startTime;
|
|
254
|
+
|
|
255
|
+
if (stdoutBuffer.trim()) {
|
|
256
|
+
try {
|
|
257
|
+
const obj = JSON.parse(stdoutBuffer);
|
|
258
|
+
if (obj.type === 'content_block_delta' && obj.delta?.text) {
|
|
259
|
+
process.stdout.write(obj.delta.text);
|
|
260
|
+
}
|
|
261
|
+
} catch {
|
|
262
|
+
process.stdout.write(stdoutBuffer + '\n');
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
process.stdout.write('\n');
|
|
266
|
+
|
|
267
|
+
if (timedOut) return;
|
|
268
|
+
|
|
269
|
+
if (logger) {
|
|
270
|
+
logger.cliCall(agentConfig.command, args, code);
|
|
271
|
+
const trimmedOutput = agentText.trim();
|
|
272
|
+
if (trimmedOutput) {
|
|
273
|
+
logger.info(`OUTPUT ↓`, stageId);
|
|
274
|
+
for (const line of trimmedOutput.split('\n')) {
|
|
275
|
+
logger.info(` ${line}`, stageId);
|
|
276
|
+
}
|
|
277
|
+
logger.info(`OUTPUT ↑`, stageId);
|
|
278
|
+
}
|
|
279
|
+
if (stderr.trim()) {
|
|
280
|
+
logger.warn(`STDERR ↓`, stageId);
|
|
281
|
+
for (const line of stderr.trim().split('\n')) {
|
|
282
|
+
logger.warn(` ${line}`, stageId);
|
|
283
|
+
}
|
|
284
|
+
logger.warn(`STDERR ↑`, stageId);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const result = resultParser.parse(stdout, stageId);
|
|
289
|
+
|
|
290
|
+
if (code !== 0 && result.parsed && result.status && result.status !== 'default') {
|
|
291
|
+
if (logger) {
|
|
292
|
+
logger.warn(
|
|
293
|
+
`Agent exited with code ${code}, but RESULT was parsed (status: ${result.status}). Using parsed result.`,
|
|
294
|
+
stageId
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
} else if (code !== 0) {
|
|
298
|
+
const err = new Error(`Agent exited with code ${code}`);
|
|
299
|
+
err.code = 'NON_ZERO_EXIT';
|
|
300
|
+
err.exitCode = code;
|
|
301
|
+
err.stderr = stderr;
|
|
302
|
+
if (logger) {
|
|
303
|
+
logger.error(`Agent exited with code ${code}`, stageId);
|
|
304
|
+
if (stderr.trim()) {
|
|
305
|
+
for (const line of stderr.trim().split('\n')) {
|
|
306
|
+
logger.error(` stderr: ${line}`, stageId);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
reject(err);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
resolve({
|
|
315
|
+
status: result.status || 'default',
|
|
316
|
+
output: stdout,
|
|
317
|
+
stderr: stderr,
|
|
318
|
+
result: result.data || {},
|
|
319
|
+
exitCode: code,
|
|
320
|
+
parsed: result.parsed,
|
|
321
|
+
durationMs
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
child.on('error', (err) => {
|
|
326
|
+
clearTimeout(timeoutId);
|
|
327
|
+
if (!timedOut) {
|
|
328
|
+
if (logger) {
|
|
329
|
+
logger.error(`CLI error: ${err.message}`, stageId);
|
|
330
|
+
}
|
|
331
|
+
reject(err);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export { ResultParser };
|
|
338
|
+
export default { spawnAgent, ResultParser };
|
package/src/runner.mjs
CHANGED
|
@@ -429,11 +429,21 @@ class ResultParser {
|
|
|
429
429
|
parse(output, stageId) {
|
|
430
430
|
const marker = '---RESULT---';
|
|
431
431
|
|
|
432
|
-
//
|
|
433
|
-
|
|
434
|
-
|
|
432
|
+
// Ищем маркеры ТОЛЬКО на отдельных строках (printResult выводит их на своих строках).
|
|
433
|
+
// Берём последнюю пару: маркер может случайно встретиться в логах/заголовках тикетов
|
|
434
|
+
// до финального блока (напр. "title: ... ---RESULT--- ..."). Regex ^---RESULT---$
|
|
435
|
+
// с multi-line флагом отсеивает такие вхождения.
|
|
436
|
+
const lineMarkerRegex = /^---RESULT---\s*$/gm;
|
|
437
|
+
const markerPositions = [];
|
|
438
|
+
let m;
|
|
439
|
+
while ((m = lineMarkerRegex.exec(output)) !== null) {
|
|
440
|
+
markerPositions.push(m.index);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const endIdx = markerPositions.length >= 2 ? markerPositions[markerPositions.length - 1] : -1;
|
|
444
|
+
const startIdx = markerPositions.length >= 2 ? markerPositions[markerPositions.length - 2] : -1;
|
|
435
445
|
|
|
436
|
-
if (startIdx !== -1 && endIdx !== -1) {
|
|
446
|
+
if (startIdx !== -1 && endIdx !== -1 && startIdx !== endIdx) {
|
|
437
447
|
// Найдены маркеры — парсим структурированный блок
|
|
438
448
|
const resultBlock = output.substring(startIdx + marker.length, endIdx).trim();
|
|
439
449
|
const data = this.parseResultBlock(resultBlock);
|
|
@@ -921,16 +931,8 @@ class StageExecutor {
|
|
|
921
931
|
};
|
|
922
932
|
}
|
|
923
933
|
|
|
924
|
-
// Курсор = (attempt - 1)
|
|
925
|
-
const cursor = attempt - 1;
|
|
926
|
-
if (cursor >= compatible.length) {
|
|
927
|
-
return {
|
|
928
|
-
blocked: 'attempts_exhausted',
|
|
929
|
-
reason: `Attempt ${attempt} exceeds compatible agents list length (${compatible.length})`,
|
|
930
|
-
attempt,
|
|
931
|
-
triedAgents: compatible
|
|
932
|
-
};
|
|
933
|
-
}
|
|
934
|
+
// Курсор = (attempt - 1) % length — ротация по кругу
|
|
935
|
+
const cursor = (attempt - 1) % compatible.length;
|
|
934
936
|
|
|
935
937
|
const agentId = compatible[cursor];
|
|
936
938
|
// Клонируем stage с подменой instructions (для agents_by_type override)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* archive-plan-tickets.js - Архивирует все done-тикеты указанного плана
|
|
5
|
+
*
|
|
6
|
+
* Использование:
|
|
7
|
+
* node archive-plan-tickets.js <plan_id>
|
|
8
|
+
*
|
|
9
|
+
* Пример:
|
|
10
|
+
* node archive-plan-tickets.js PLAN-002
|
|
11
|
+
* node archive-plan-tickets.js 2
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import { findProjectRoot } from 'workflow-ai/lib/find-root.mjs';
|
|
17
|
+
import { parseFrontmatter, serializeFrontmatter, normalizePlanId, extractPlanId, printResult } from 'workflow-ai/lib/utils.mjs';
|
|
18
|
+
|
|
19
|
+
const PROJECT_DIR = findProjectRoot();
|
|
20
|
+
const WORKFLOW_DIR = path.join(PROJECT_DIR, '.workflow');
|
|
21
|
+
const TICKETS_DIR = path.join(WORKFLOW_DIR, 'tickets');
|
|
22
|
+
const DONE_DIR = path.join(TICKETS_DIR, 'done');
|
|
23
|
+
const ARCHIVE_DIR = path.join(TICKETS_DIR, 'archive');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Архивирует все done-тикеты указанного плана
|
|
27
|
+
*/
|
|
28
|
+
function archivePlanTickets(planId) {
|
|
29
|
+
if (!planId) {
|
|
30
|
+
return { status: 'error', error: 'Missing plan_id' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!fs.existsSync(DONE_DIR)) {
|
|
34
|
+
return { status: 'ok', plan_id: planId, archived: 0, ticket_ids: '' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!fs.existsSync(ARCHIVE_DIR)) {
|
|
38
|
+
fs.mkdirSync(ARCHIVE_DIR, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const files = fs.readdirSync(DONE_DIR).filter(f => f.endsWith('.md') && f !== '.gitkeep.md');
|
|
42
|
+
const archived = [];
|
|
43
|
+
|
|
44
|
+
for (const file of files) {
|
|
45
|
+
const filePath = path.join(DONE_DIR, file);
|
|
46
|
+
try {
|
|
47
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
48
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
49
|
+
|
|
50
|
+
const ticketPlanId = normalizePlanId(frontmatter.parent_plan);
|
|
51
|
+
if (ticketPlanId !== planId) continue;
|
|
52
|
+
|
|
53
|
+
const ticketId = frontmatter.id || file.replace('.md', '');
|
|
54
|
+
|
|
55
|
+
frontmatter.updated_at = new Date().toISOString();
|
|
56
|
+
frontmatter.archived_at = new Date().toISOString();
|
|
57
|
+
|
|
58
|
+
const destPath = path.join(ARCHIVE_DIR, file);
|
|
59
|
+
fs.writeFileSync(destPath, serializeFrontmatter(frontmatter) + body, 'utf8');
|
|
60
|
+
fs.unlinkSync(filePath);
|
|
61
|
+
|
|
62
|
+
archived.push(ticketId);
|
|
63
|
+
console.log(`[ARCHIVE] ${ticketId}: done → archive`);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
console.error(`[ERROR] Failed to archive ${file}: ${e.message}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
status: 'ok',
|
|
71
|
+
plan_id: planId,
|
|
72
|
+
archived: archived.length,
|
|
73
|
+
ticket_ids: archived.join(',')
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Main entry point
|
|
78
|
+
const rawArgs = process.argv.slice(2);
|
|
79
|
+
let planId;
|
|
80
|
+
|
|
81
|
+
if (rawArgs.length >= 1) {
|
|
82
|
+
// Прямой вызов или pipeline context
|
|
83
|
+
const arg = rawArgs[0];
|
|
84
|
+
const planMatch = arg.match(/plan_id:\s*(\S+)/i);
|
|
85
|
+
planId = planMatch ? normalizePlanId(planMatch[1]) : normalizePlanId(arg);
|
|
86
|
+
} else {
|
|
87
|
+
planId = extractPlanId();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!planId) {
|
|
91
|
+
console.error('Usage: node archive-plan-tickets.js <plan_id>');
|
|
92
|
+
console.error('Example: node archive-plan-tickets.js PLAN-002');
|
|
93
|
+
printResult({ status: 'error', error: 'Missing plan_id argument' });
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const result = archivePlanTickets(planId);
|
|
98
|
+
printResult(result);
|
|
99
|
+
|
|
100
|
+
if (result.status === 'error') {
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* check-anomalies.js - Скрипт для проверки аномалий в тикетах
|
|
5
|
+
*
|
|
6
|
+
* Проверяет in-progress тикеты на наличие заполненных результатов.
|
|
7
|
+
* Если тикет в in-progress, но имеет заполненный раздел "Результат выполнения" —
|
|
8
|
+
* это аномалия (тикет, вероятно, выполнен, но не перемещён в done/review).
|
|
9
|
+
*
|
|
10
|
+
* Использование:
|
|
11
|
+
* node check-anomalies.js
|
|
12
|
+
*
|
|
13
|
+
* Выводит результат в формате:
|
|
14
|
+
* ---RESULT---
|
|
15
|
+
* status: ok|anomalies_found|error
|
|
16
|
+
* anomalies_count: N
|
|
17
|
+
* anomalies: [{"id": "IMPL-001", "title": "...", "recommendation": "..."}]
|
|
18
|
+
* ---RESULT---
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from 'fs';
|
|
22
|
+
import path from 'path';
|
|
23
|
+
import YAML from 'workflow-ai/lib/js-yaml.mjs';
|
|
24
|
+
import { findProjectRoot } from 'workflow-ai/lib/find-root.mjs';
|
|
25
|
+
import { parseFrontmatter, printResult } from 'workflow-ai/lib/utils.mjs';
|
|
26
|
+
|
|
27
|
+
// Корень проекта
|
|
28
|
+
const PROJECT_DIR = findProjectRoot();
|
|
29
|
+
const TICKETS_DIR = path.join(PROJECT_DIR, '.workflow', 'tickets');
|
|
30
|
+
const IN_PROGRESS_DIR = path.join(TICKETS_DIR, 'in-progress');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Проверяет, заполнен ли раздел результатов
|
|
34
|
+
* Возвращает true, если раздел содержит реальный контент (не только комментарии)
|
|
35
|
+
*/
|
|
36
|
+
function hasFilledResult(body) {
|
|
37
|
+
// Ищем раздел "Результат выполнения" или "Result"
|
|
38
|
+
// Используем более гибкий паттерн
|
|
39
|
+
const resultSectionRegex = /^##\s*(Результат выполнения|Result)\s*$/m;
|
|
40
|
+
const sectionStart = body.search(resultSectionRegex);
|
|
41
|
+
|
|
42
|
+
if (sectionStart === -1) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Находим начало следующей секции ## или конец файла
|
|
47
|
+
const nextSectionRegex = /^##\s+/gm;
|
|
48
|
+
nextSectionRegex.lastIndex = sectionStart + 1;
|
|
49
|
+
const nextSectionMatch = nextSectionRegex.exec(body);
|
|
50
|
+
const sectionEnd = nextSectionMatch ? nextSectionMatch.index : body.length;
|
|
51
|
+
|
|
52
|
+
const sectionContent = body.substring(sectionStart, sectionEnd);
|
|
53
|
+
|
|
54
|
+
// Ищем подраздел Summary или "Что сделано"
|
|
55
|
+
const summaryRegex = /^###\s*(Summary|Что сделано)\s*$/m;
|
|
56
|
+
const summaryStart = sectionContent.search(summaryRegex);
|
|
57
|
+
|
|
58
|
+
if (summaryStart === -1) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Находим начало следующего подраздела ### или конец секции
|
|
63
|
+
const nextSubsectionRegex = /^###\s+/gm;
|
|
64
|
+
nextSubsectionRegex.lastIndex = summaryStart + 1;
|
|
65
|
+
const nextSubsectionMatch = nextSubsectionRegex.exec(sectionContent);
|
|
66
|
+
const summaryEnd = nextSubsectionMatch ? nextSubsectionMatch.index : sectionContent.length;
|
|
67
|
+
|
|
68
|
+
const summaryContent = sectionContent.substring(summaryStart, summaryEnd);
|
|
69
|
+
|
|
70
|
+
// Проверяем, что контент не пустой и не состоит только из комментариев
|
|
71
|
+
// Удаляем HTML комментарии и проверяем остаток
|
|
72
|
+
const withoutComments = summaryContent.replace(/<!--[\s\S]*?-->/g, '').trim();
|
|
73
|
+
|
|
74
|
+
// Если после удаления комментариев остался текст — раздел заполнен
|
|
75
|
+
return withoutComments.length > 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Основная функция проверки аномалий
|
|
80
|
+
*/
|
|
81
|
+
async function checkAnomalies() {
|
|
82
|
+
const anomalies = [];
|
|
83
|
+
|
|
84
|
+
// Проверяем существование директории in-progress
|
|
85
|
+
if (!fs.existsSync(IN_PROGRESS_DIR)) {
|
|
86
|
+
return {
|
|
87
|
+
status: 'ok',
|
|
88
|
+
anomalies_count: 0,
|
|
89
|
+
anomalies: [],
|
|
90
|
+
message: 'in-progress directory does not exist'
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Читаем все файлы в in-progress
|
|
95
|
+
let files;
|
|
96
|
+
try {
|
|
97
|
+
files = fs.readdirSync(IN_PROGRESS_DIR);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return {
|
|
100
|
+
status: 'error',
|
|
101
|
+
error: `Failed to read in-progress directory: ${e.message}`
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Фильтруем .md файлы (исключаем .gitkeep)
|
|
106
|
+
const ticketFiles = files.filter(f => f.endsWith('.md') && f !== '.gitkeep.md');
|
|
107
|
+
|
|
108
|
+
for (const file of ticketFiles) {
|
|
109
|
+
const filePath = path.join(IN_PROGRESS_DIR, file);
|
|
110
|
+
let content;
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
114
|
+
} catch (e) {
|
|
115
|
+
anomalies.push({
|
|
116
|
+
id: file.replace('.md', ''),
|
|
117
|
+
title: 'Unknown (read error)',
|
|
118
|
+
recommendation: `Не удалось прочитать файл: ${e.message}`
|
|
119
|
+
});
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Парсим frontmatter для получения id и title
|
|
124
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
125
|
+
const ticketId = frontmatter.id || file.replace('.md', '');
|
|
126
|
+
const ticketTitle = frontmatter.title || 'Unknown';
|
|
127
|
+
|
|
128
|
+
// Проверяем наличие заполненного результата
|
|
129
|
+
if (hasFilledResult(body)) {
|
|
130
|
+
anomalies.push({
|
|
131
|
+
id: ticketId,
|
|
132
|
+
title: ticketTitle,
|
|
133
|
+
recommendation: 'Проверьте тикет и переместите в done/ или review/ если выполнен'
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
status: anomalies.length > 0 ? 'anomalies_found' : 'ok',
|
|
140
|
+
anomalies_count: anomalies.length,
|
|
141
|
+
anomalies: anomalies
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Main entry point
|
|
146
|
+
checkAnomalies().then(result => {
|
|
147
|
+
printResult(result);
|
|
148
|
+
|
|
149
|
+
// Если найдены аномалии, выводим их в читаемом виде
|
|
150
|
+
if (result.anomalies && result.anomalies.length > 0) {
|
|
151
|
+
console.log('\n[ANOMALIES DETECTED]');
|
|
152
|
+
for (const anomaly of result.anomalies) {
|
|
153
|
+
console.log(` - ${anomaly.id}: ${anomaly.title}`);
|
|
154
|
+
console.log(` Recommendation: ${anomaly.recommendation}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (result.status === 'error') {
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
});
|