workflow-ai 1.3.1 → 1.5.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [1.5.0] — 2026-05-02
2
+
3
+ ### Added
4
+ - **Audit log агентов в тикетах**: каждая попытка агента (включая fallback) пишет строку в секцию `## История работы` тикета: `| Дата/время | Скил | Агент | Статус |`. 10 классов статуса (ok, error, timeout, empty_response, rate_limit, network_error, auth_error, aborted, blocked, skipped_relevance) детектируются автоматически.
5
+ - **Колонка Агент в `## Ревью`**: миграция 3→4 колонки. Видно кто проставил вердикт.
6
+ - **Agent history aggregation в metrics**: ключ `agent_history` в `metrics/review-metrics.json` с разрезами by_status/by_agent/by_skill/by_skill_by_agent + fallback_stats. Incremental update от runner.
7
+ - New helpers: `lib/agent-history.mjs`, `lib/review-section.mjs`, `lib/metrics-incremental.mjs`.
8
+
9
+ ### Changed
10
+ - `getLastReviewStatus` parsing теперь header-based (whitelist: Статус/Status/Вердикт/Verdict). Поддержка legacy 3-колоночных таблиц через `unknown` placeholder в Agent при first append.
11
+ - `verify-artifacts.js`, `move-ticket.js` fallback, `check-relevance.js` теперь пишут review через `appendReviewEntry` helper.
12
+
1
13
  ## [1.3.0] — 2026-04-30
2
14
 
3
15
  ### Добавлено
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workflow-ai",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "description": "AI Agent Workflow Coordinator — kanban-based pipeline for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
package/src/lib/utils.mjs CHANGED
@@ -98,78 +98,7 @@ export function getPackageRoot() {
98
98
  return path.resolve(__dirname, '../../');
99
99
  }
100
100
 
101
- /**
102
- * Парсит секцию "## Ревью" тикета и возвращает статус последней записи.
103
- * Поддерживает табличный и текстовый форматы.
104
- *
105
- * Табличный формат:
106
- * | Дата | Статус | Комментарий |
107
- * |------|--------|-------------|
108
- * | 2026-03-08 | passed | Всё ок |
109
- *
110
- * Текстовый формат:
111
- * - 2026-03-08: passed - Всё ок
112
- * - 2026-03-08: failed - Есть замечания
113
- *
114
- * @param {string} content - Содержимое тикета (markdown)
115
- * @returns {string|null} "passed", "failed" или null (если нет ревью)
116
- */
117
- export function getLastReviewStatus(content) {
118
- if (!content) return null;
119
-
120
- // Находим последний заголовок H2 "## Ревью" (только строки начинающиеся с "## ")
121
- const lines = content.split('\n');
122
- let lastHeaderLineIndex = -1;
123
-
124
- for (let i = 0; i < lines.length; i++) {
125
- if (lines[i].startsWith('## ') && lines[i].includes('Ревью')) {
126
- lastHeaderLineIndex = i;
127
- }
128
- }
129
-
130
- if (lastHeaderLineIndex === -1) return null;
131
-
132
- // Собираем содержимое после заголовка до следующего H2 заголовка
133
- const reviewLines = [];
134
- for (let i = lastHeaderLineIndex + 1; i < lines.length; i++) {
135
- if (lines[i].startsWith('## ')) break; // следующий H2 заголовок
136
- reviewLines.push(lines[i]);
137
- }
138
-
139
- const reviewSection = reviewLines.join('\n').trim();
140
- if (!reviewSection) return null;
141
-
142
- // Пробуем распарсить табличный формат
143
- const tableRows = reviewSection.split('\n').filter(line => line.trim().startsWith('|'));
144
- if (tableRows.length >= 2) {
145
- // Есть заголовок и разделитель, ищем строки с данными
146
- const dataRows = tableRows.slice(2).filter(row => {
147
- const cells = row.split('|').map(c => c.trim()).filter(c => c);
148
- return cells.length >= 2;
149
- });
150
-
151
- if (dataRows.length > 0) {
152
- // Последняя строка таблицы = самое свежее ревью (записи ведутся хронологически сверху вниз)
153
- const latestRow = dataRows[dataRows.length - 1];
154
- const cells = latestRow.split('|').map(c => c.trim()).filter(c => c);
155
- const statusRaw = cells[1]?.toLowerCase() || '';
156
- if (statusRaw.includes('passed')) return 'passed';
157
- if (statusRaw.includes('failed')) return 'failed';
158
- if (statusRaw.includes('skipped')) return 'skipped';
159
- }
160
- }
161
-
162
- // Пробуем распарсить текстовый формат (список)
163
- const listItems = reviewSection.split('\n').filter(line => line.trim().match(/^[-*]\s/));
164
- if (listItems.length > 0) {
165
- // Последний элемент списка = самое свежее ревью (записи ведутся хронологически)
166
- const latestItem = listItems[listItems.length - 1].trim();
167
- const statusMatch = latestItem.match(/:\s*(passed|failed|skipped)\b/i);
168
- if (statusMatch) return statusMatch[1].toLowerCase();
169
- }
170
-
171
- return null;
172
- }
101
+ export { getLastReviewStatus, appendReviewEntry } from '../../workflow-ai/src/lib/review-section.mjs';
173
102
 
174
103
  /**
175
104
  * Загружает конфигурацию правил перемещения тикетов.
package/src/runner.mjs CHANGED
@@ -10,6 +10,93 @@ import { loadRules, scanStderrForFatalRule, classify } from './lib/error-classif
10
10
  import { snapshot, diff, isEmpty } from './lib/artifact-snapshot.mjs';
11
11
  import { markUnhealthy, isHealthy } from './lib/agent-health-registry.mjs';
12
12
  import { writeMarker, removeMarker } from './lib/marker.mjs';
13
+ import { appendAgentRun, classifyAgentResult } from '../workflow-ai/src/lib/agent-history.mjs';
14
+ import { incrementMetrics } from '../workflow-ai/src/lib/metrics-incremental.mjs';
15
+
16
+ // ============================================================================
17
+ // Audit-log helpers (used by executeWithFallback hook — IMPL-83)
18
+ // ============================================================================
19
+
20
+ function formatLocalDateTime(d) {
21
+ const pad = n => String(n).padStart(2, '0');
22
+ return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
23
+ }
24
+
25
+ function findTicketPathForId(ticketId, projectRoot) {
26
+ if (!ticketId) return null;
27
+ const dirs = ['ready', 'in-progress', 'review', 'done', 'backlog', 'blocked'];
28
+ for (const d of dirs) {
29
+ const p = path.join(projectRoot, '.workflow', 'tickets', d, `${ticketId}.md`);
30
+ try { if (fs.existsSync(p)) return p; } catch {}
31
+ }
32
+ return null;
33
+ }
34
+
35
+ // IMPL-86: normalize agent_id in last row of ## Ревью section
36
+ // Re-writes the agent column if it differs from expectedAgent. Other columns untouched.
37
+ // Returns { ok: true, changed: boolean } or { ok: false, code, error }.
38
+ function normalizeReviewAgentId(ticketPath, expectedAgent) {
39
+ if (!ticketPath || !expectedAgent) return { ok: false, code: 'INVALID_INPUT' };
40
+ let content;
41
+ try {
42
+ content = fs.readFileSync(ticketPath, 'utf8');
43
+ } catch (err) {
44
+ return { ok: false, code: 'READ_ERROR', error: err.message };
45
+ }
46
+
47
+ const sectionRegex = /(##\s+Ревью\s*\r?\n)([\s\S]*?)(?=\r?\n##\s+|$)/i;
48
+ const match = content.match(sectionRegex);
49
+ if (!match) return { ok: false, code: 'NO_SECTION' };
50
+
51
+ const sectionBody = match[2];
52
+ const lines = sectionBody.split('\n');
53
+ let headerIdx = -1;
54
+ for (let i = 0; i < lines.length; i++) {
55
+ const t = lines[i].trim();
56
+ if (t.startsWith('|') && t.endsWith('|') && !/^\s*\|[-:|\s]+\|\s*$/.test(t)) {
57
+ headerIdx = i;
58
+ break;
59
+ }
60
+ }
61
+ if (headerIdx === -1) return { ok: false, code: 'NO_HEADER' };
62
+
63
+ const headerCells = lines[headerIdx].trim().slice(1, -1).split(/(?<!\\)\|/).map(c => c.trim());
64
+ const agentColIdx = headerCells.findIndex(c => /^(агент|agent)$/i.test(c));
65
+ if (agentColIdx === -1) return { ok: false, code: 'NO_AGENT_COLUMN' };
66
+
67
+ let lastDataIdx = -1;
68
+ for (let i = lines.length - 1; i > headerIdx; i--) {
69
+ const t = lines[i].trim();
70
+ if (t.startsWith('|') && t.endsWith('|') && !/^\s*\|[-:|\s]+\|\s*$/.test(t)) {
71
+ lastDataIdx = i;
72
+ break;
73
+ }
74
+ }
75
+ if (lastDataIdx === -1) return { ok: false, code: 'NO_DATA_ROW' };
76
+
77
+ const cells = lines[lastDataIdx].trim().slice(1, -1).split(/(?<!\\)\|/).map(c => c.trim());
78
+ if (agentColIdx >= cells.length) return { ok: false, code: 'CELL_MISSING' };
79
+
80
+ const currentAgent = cells[agentColIdx];
81
+ if (currentAgent === expectedAgent) return { ok: true, changed: false };
82
+
83
+ cells[agentColIdx] = expectedAgent;
84
+ lines[lastDataIdx] = `| ${cells.join(' | ')} |`;
85
+
86
+ const newSection = match[1] + lines.join('\n');
87
+ const newContent = content.replace(sectionRegex, newSection);
88
+
89
+ const dir = path.dirname(ticketPath);
90
+ const tmp = path.join(dir, `.${path.basename(ticketPath)}.normagent.${process.pid}.${Date.now()}`);
91
+ try {
92
+ fs.writeFileSync(tmp, newContent, 'utf8');
93
+ fs.renameSync(tmp, ticketPath);
94
+ return { ok: true, changed: true };
95
+ } catch (err) {
96
+ try { fs.unlinkSync(tmp); } catch {}
97
+ return { ok: false, code: 'WRITE_ERROR', error: err.message };
98
+ }
99
+ }
13
100
 
14
101
  // ============================================================================
15
102
  // Logger — система логирования с уровнями DEBUG/INFO/WARN/ERROR
@@ -1041,6 +1128,29 @@ class StageExecutor {
1041
1128
 
1042
1129
  const result = await this.callAgent(agent, prompt, stageId, effectiveStage.skill, agentId);
1043
1130
 
1131
+ // IMPL-83: audit-log hook (success path)
1132
+ await this._auditAgentRun(stageId, effectiveStage, agentId, {
1133
+ exitCode: result.exitCode ?? 0,
1134
+ stderr: '',
1135
+ stdout: result.output || '',
1136
+ parsedResult: result.result || null,
1137
+ });
1138
+
1139
+ // IMPL-86: normalize agent_id in ## Ревью after review-result stage
1140
+ if ((effectiveStage.skill === 'review-result' || stageId === 'review-result') && this.context?.ticket_id) {
1141
+ try {
1142
+ const tp = findTicketPathForId(this.context.ticket_id, this.projectRoot);
1143
+ if (tp) {
1144
+ const r = normalizeReviewAgentId(tp, agentId);
1145
+ if (!r.ok && r.code !== 'NO_SECTION' && r.code !== 'NO_DATA_ROW' && this.logger) {
1146
+ this.logger.warn(`review agent normalize: ${r.code} ${r.error || ''}`, stageId);
1147
+ }
1148
+ }
1149
+ } catch (err) {
1150
+ if (this.logger) this.logger.warn(`review agent normalize threw: ${err.message}`, stageId);
1151
+ }
1152
+ }
1153
+
1044
1154
  if (this.logger) this.logger.stageComplete(stageId, result.status, result.exitCode);
1045
1155
  return result;
1046
1156
  } catch (err) {
@@ -1049,6 +1159,16 @@ class StageExecutor {
1049
1159
  const exitCode = err.exitCode ?? err.code;
1050
1160
  const stderr = err.stderr || '';
1051
1161
 
1162
+ // IMPL-83: audit-log hook (failure path)
1163
+ await this._auditAgentRun(stageId, effectiveStage, agentId, {
1164
+ exitCode,
1165
+ stderr,
1166
+ stdout: err.stdout || '',
1167
+ parsedResult: err.parsedResult || null,
1168
+ timedOut: err.timedOut === true,
1169
+ signal: err.signal,
1170
+ });
1171
+
1052
1172
  const after = snapshotEnabled ? await snapshot(this.projectRoot, snapshotOpts) : null;
1053
1173
  const diffResult = snapshotEnabled ? diff(before, after) : null;
1054
1174
  const diffEmpty = snapshotEnabled && isEmpty(diffResult);
@@ -1088,6 +1208,68 @@ class StageExecutor {
1088
1208
  }
1089
1209
  }
1090
1210
 
1211
+ /**
1212
+ * IMPL-83: Audit-log hook — write entry to ticket history + bump metrics.
1213
+ * Non-blocking: errors are logged via logger.warn, never thrown.
1214
+ */
1215
+ async _auditAgentRun(stageId, effectiveStage, agentId, callResult) {
1216
+ try {
1217
+ const ticketId = this.context?.ticket_id;
1218
+ if (!ticketId) return;
1219
+
1220
+ const agentType = (agentId || '').startsWith('script-') ? 'script' : 'ai';
1221
+ const status = classifyAgentResult({
1222
+ exitCode: callResult.exitCode ?? 0,
1223
+ stderr: callResult.stderr || '',
1224
+ stdout: callResult.stdout || '',
1225
+ timedOut: callResult.timedOut === true,
1226
+ signal: callResult.signal,
1227
+ parsedResult: callResult.parsedResult || null,
1228
+ agentType,
1229
+ });
1230
+
1231
+ // For move-* stages prefer destination from parsedResult.to
1232
+ let ticketPath = null;
1233
+ if (stageId.startsWith('move-') && callResult.parsedResult?.to) {
1234
+ ticketPath = path.join(this.projectRoot, '.workflow/tickets', callResult.parsedResult.to, `${ticketId}.md`);
1235
+ try { if (!fs.existsSync(ticketPath)) ticketPath = null; } catch { ticketPath = null; }
1236
+ }
1237
+ if (!ticketPath) {
1238
+ ticketPath = findTicketPathForId(ticketId, this.projectRoot);
1239
+ }
1240
+ if (!ticketPath) return;
1241
+
1242
+ const skillName = effectiveStage.skill || stageId;
1243
+ const entry = {
1244
+ timestamp: formatLocalDateTime(new Date()),
1245
+ skill: skillName,
1246
+ agent: agentId || 'unknown',
1247
+ status,
1248
+ };
1249
+
1250
+ try {
1251
+ const r = appendAgentRun(ticketPath, entry);
1252
+ if (!r?.ok && this.logger) {
1253
+ this.logger.warn(`audit-log appendAgentRun failed: ${r?.code || 'unknown'} ${r?.error || ''}`, stageId);
1254
+ }
1255
+ } catch (err) {
1256
+ if (this.logger) this.logger.warn(`audit-log appendAgentRun threw: ${err.message}`, stageId);
1257
+ }
1258
+
1259
+ try {
1260
+ const r = incrementMetrics(this.projectRoot, entry, ticketId);
1261
+ if (!r?.ok && this.logger) {
1262
+ this.logger.warn(`metrics update failed: ${r?.code || 'unknown'} ${r?.error || ''}`, stageId);
1263
+ }
1264
+ } catch (err) {
1265
+ if (this.logger) this.logger.warn(`metrics update threw: ${err.message}`, stageId);
1266
+ }
1267
+ } catch (outer) {
1268
+ // Final safety net — never let audit-log break the pipeline
1269
+ if (this.logger) this.logger.warn(`audit-log hook crashed: ${outer.message}`, stageId);
1270
+ }
1271
+ }
1272
+
1091
1273
  /**
1092
1274
  * Выполняет stage через выбранного CLI-агента (новая модель выбора).
1093
1275
  * @param {string} stageId - ID stage из конфигурации
@@ -1197,7 +1379,10 @@ class StageExecutor {
1197
1379
  if (this.logger) {
1198
1380
  this.logger.timeout(stageId, timeout);
1199
1381
  }
1200
- reject(new Error(`Stage "${stageId}" timed out after ${timeout}s`));
1382
+ const err = new Error(`Stage "${stageId}" timed out after ${timeout}s`);
1383
+ err.timedOut = true;
1384
+ err.exitCode = -1;
1385
+ reject(err);
1201
1386
  }, timeout * 1000);
1202
1387
 
1203
1388
  let stdoutBuffer = '';
@@ -1271,7 +1456,7 @@ class StageExecutor {
1271
1456
  reject(err);
1272
1457
  });
1273
1458
 
1274
- child.on('close', (code) => {
1459
+ child.on('close', (code, signal) => {
1275
1460
  this.currentChild = null;
1276
1461
  clearTimeout(timeoutId);
1277
1462
  // Обрабатываем остаток буфера стриминга
@@ -1340,6 +1525,7 @@ class StageExecutor {
1340
1525
  err.code = 'NON_ZERO_EXIT';
1341
1526
  err.exitCode = code;
1342
1527
  err.stderr = stderr;
1528
+ err.signal = signal;
1343
1529
  if (this.logger) {
1344
1530
  this.logger.error(`Agent exited with code ${code}`, stageId);
1345
1531
  if (stderr.trim()) {
@@ -21,6 +21,7 @@ import {
21
21
  parseFrontmatter,
22
22
  serializeFrontmatter,
23
23
  getLastReviewStatus,
24
+ appendReviewEntry,
24
25
  } from "workflow-ai/lib/utils.mjs";
25
26
 
26
27
  const PROJECT_DIR = findProjectRoot();
@@ -212,43 +213,18 @@ async function checkRelevance(ticketPath) {
212
213
  return { verdict: "relevant", reason: "all_checks_passed" };
213
214
  }
214
215
 
216
+ // IMPL-89: Replace manual markdown-write with appendReviewEntry from review-section.mjs.
215
217
  function addSkippedReview(ticketPath, reason) {
216
- const now = new Date();
217
- const date = now.toISOString().slice(0, 10);
218
-
219
- let content;
220
- try {
221
- content = fs.readFileSync(ticketPath, "utf8");
222
- } catch (e) {
223
- throw new Error(`Failed to read ticket: ${e.message}`);
224
- }
225
-
226
- let { frontmatter, body } = parseFrontmatter(content);
227
-
228
- const reviewSectionMatch = body.match(/##\s*Ревью\s*\n([\s\S]*)/i);
229
- let newBody;
230
-
231
- if (reviewSectionMatch) {
232
- const reviewContent = reviewSectionMatch[1];
233
- const lines = reviewContent.split("\n");
234
- let insertIndex = 0;
235
- for (let i = 0; i < lines.length; i++) {
236
- if (lines[i].trim().startsWith("|") && lines[i].includes("---")) {
237
- insertIndex = i + 1;
238
- break;
239
- }
240
- }
241
-
242
- const newRow = `| ${date} | ⏭️ skipped | ${reason} |`;
243
- lines.splice(insertIndex, 0, newRow);
244
- newBody = body.slice(0, reviewSectionMatch.index) + lines.join("\n");
245
- } else {
246
- const reviewTable = `\n## Ревью\n\n| Дата | Статус | Самари |\n|------|--------|--------|\n| ${date} | ⏭️ skipped | ${reason} |\n`;
247
- newBody = body.trimEnd() + reviewTable;
218
+ const date = new Date().toISOString().slice(0, 10);
219
+ const r = appendReviewEntry(ticketPath, {
220
+ date,
221
+ agent: 'script-check-relevance',
222
+ status: 'skipped',
223
+ summary: reason,
224
+ });
225
+ if (!r?.ok) {
226
+ throw new Error(`addSkippedReview failed: ${r?.code || 'unknown'} ${r?.error || ''}`);
248
227
  }
249
-
250
- const newContent = serializeFrontmatter(frontmatter) + newBody;
251
- fs.writeFileSync(ticketPath, newContent, "utf8");
252
228
  }
253
229
 
254
230
  async function main() {
@@ -20,6 +20,7 @@ import {
20
20
  printResult,
21
21
  serializeFrontmatter,
22
22
  getLastReviewStatus,
23
+ appendReviewEntry,
23
24
  } from "workflow-ai/lib/utils.mjs";
24
25
 
25
26
  const logger = {
@@ -210,13 +211,19 @@ async function moveTicket(ticketId, target) {
210
211
  }
211
212
 
212
213
  // Fallback: если тикет идёт в done из review, но агент не записал секцию "## Ревью" — дописываем
214
+ // IMPL-88: используем appendReviewEntry вместо ручного markdown-write.
215
+ // ВАЖНО: пишем напрямую через body manipulation (а не file rewrite через appendReviewEntry),
216
+ // потому что move-ticket в этой же транзакции serializeFrontmatter(...) + renameSync — иначе
217
+ // запись в исходный файл потеряется при rename. Используем те же поля что и appendReviewEntry.
213
218
  if (
214
219
  target === "done" &&
215
220
  currentStatus === "review" &&
216
221
  getLastReviewStatus(content) === null
217
222
  ) {
218
223
  const date = now.slice(0, 16).replace("T", " ");
219
- const reviewSection = `\n## Ревью\n\n| Дата | Статус | Самари |\n|------|--------|--------|\n| ${date} | ✅ passed | Pipeline fallback: агент не записал секцию ревью |\n`;
224
+ const summary = "Pipeline fallback: агент не записал секцию ревью";
225
+ const agent = "script-move-fallback";
226
+ const reviewSection = `\n## Ревью\n\n| Дата | Статус | Самари | Агент |\n|------|--------|--------|-------|\n| ${date} | ✅ passed | ${summary} | ${agent} |\n`;
220
227
  body = body.trimEnd() + "\n" + reviewSection;
221
228
  }
222
229
 
@@ -139,13 +139,17 @@ issues:
139
139
 
140
140
  ## Формат секции ревью в тикете
141
141
 
142
+ При записи результата ревью используй 4-колоночный формат таблицы.
143
+ Колонки в порядке: **Дата → Статус → Самари → Агент** (Агент идёт последним, чтобы основная информация о результате ревью читалась слева направо без шумовой колонки в начале).
144
+ В колонку Агент запиши имя своей модели (claude-sonnet, claude-haiku и т.д.). Если не знаешь точного имени — пиши `unknown`.
145
+
142
146
  ```markdown
143
147
  ## Ревью
144
148
 
145
- | Дата | Статус | Самари |
146
- |------|--------|--------|
147
- | 2026-03-25 14:30 | ❌ failed | Не пройдены тесты, отсутствует файл X |
148
- | 2026-03-25 15:45 | ✅ passed | Все критерии DoD выполнены |
149
+ | Дата | Статус | Самари | Агент |
150
+ |------|--------|--------|-------|
151
+ | 2026-03-25 14:30 | ❌ failed | Не пройдены тесты, отсутствует файл X | claude-sonnet |
152
+ | 2026-03-25 15:45 | ✅ passed | Все критерии DoD выполнены | claude-sonnet |
149
153
  ```
150
154
 
151
155
  > **Порядок записей:** хронологический сверху вниз. Последняя строка = последнее ревью.