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 +12 -0
- package/package.json +1 -1
- package/src/lib/utils.mjs +1 -72
- package/src/runner.mjs +188 -2
- package/src/scripts/check-relevance.js +11 -35
- package/src/scripts/move-ticket.js +8 -1
- package/src/skills/review-result/SKILL.md +8 -4
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
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
|
-
|
|
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
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
|
|
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
|
|
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
|
> **Порядок записей:** хронологический сверху вниз. Последняя строка = последнее ревью.
|