workflow-ai 1.3.0 → 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,61 +1,73 @@
1
- ## [1.3.0] — 2026-04-30
2
-
3
- ### Добавлено
4
- - **Новый тип стейджа: `mark-blocked` и расширение frontmatter** Добавлены поля `auto_blocked_reason`, `auto_blocked_attempts`, `auto_blocked_at` для автоматического отслеживания причин и попыток блокировки тикетов. Стейдж `mark-blocked` позволяет устанавливать эти поля в зависимости от условий бизнес-логики.
5
- - **Новый тип стейджа: `manual-gate-human`** — Добавлен статус `human_ready` в `pick-next-task.js`. При достижении этого статуса задача ожидает ручного решения оператора перед продолжением выполнения.
6
- - **Хук одобрения в `move-ticket.js`** Добавлен approval-hook, проверяющий наличие ожидающих решения одобрений перед перемещением тикета в терминальные состояния.
7
- - **Расширение START-лога полем `ticket="X"`** — Добавлено поле с идентификатором тикета в стартовый лог для улучшенной трассировки и связывания логов с конкретными задачами.
8
- - **Исправление `approval-pending.mjs` (поле `created_at`)** — Исправлено некорректное заполнение поля `created_at` при создании файлов ожидающих решения.
9
-
10
- > **Примечание:** версия 1.2.0 не была опубликована в npm. При выполнении `npm publish`
11
- > сработал prepublishOnly/postversion скрипт, автоматически поднявший версию до 1.2.1.
12
- > Git-тег `v1.2.0` (коммит 179d52b) соответствует состоянию до publish; `v1.2.1` (коммит 83d1b70) —
13
- > фактически опубликованной версии.
14
-
15
- ### New Features
16
- - **New built-in stage type: `manual-gate`** Adds support for manual approval steps in pipelines. When a stage with `type: manual-gate` is encountered, the runner creates a pending approval file in `.workflow/approvals/{step_id}.json` and enters a polling loop, waiting for an external decision (`approved`/`rejected`).
17
-
18
- **Key capabilities:**
19
- - Deterministic `step_id` generation: `{ticket_id}_{stageId}_{attempt}` (e.g., `QA-12_manual-approve_0`)
20
- - Idempotent file creationpending file is not overwritten on retry/restart
21
- - Configurable polling interval (`poll_interval_ms`, default 2000ms) and optional timeout (`timeout_seconds`)
22
- - Graceful handling of SIGTERM/runner stop (returns `aborted`)
23
- - Immediate return if file already has `approved`/`rejected` status (crash recovery)
24
- - JSON approval file format with full audit trail (`created_at`, `updated_at`, `decided_by`, `comment`, `context_snapshot`)
25
-
26
- **Pipeline configuration example:**
27
- ```yaml
28
- stages:
29
- manual-approve-deploy:
30
- type: manual-gate
31
- poll_interval_ms: 2000
32
- timeout_seconds: 86400
33
- goto:
34
- approved: continue-deploy
35
- rejected: rollback
36
- timeout: notify-stuck
37
- aborted: end
38
- ```
39
-
40
- **Two approval methods (both opt-in):**
41
- 1. **External MCP/client**: tools like `workflow-mcp` can write to approval files programmatically
42
- 2. **Direct file edit**: users can simply edit `.workflow/approvals/{step_id}.json` and change `status` to `approved` or `rejected`
43
-
44
- **Important:** `manual-gate` is **opt-in** — pipelines without such stages work identically to previous versions. No breaking changes.
45
-
46
- ### Changed
47
- - No breaking changes. All existing pipelines without `manual-gate` stages are fully backward compatible.
48
-
49
- ### Fixes
50
- - Исправлено сохранение временной метки `created_at` в файлах одобрений (approval-pending.mjs)
51
-
52
- ### Technical Notes
53
- - Approval files are stored in `<project_root>/.workflow/approvals/`
54
- - Runner validates `manual-gate` stages on startup requires `goto.approved` and `goto.rejected`, validates numeric parameters
55
- - New methods added to `PipelineRunner`: `computeStepId()`, `writeApprovalPending()`, `readApprovalFile()`, `executeManualGate()`
56
-
57
- ### References
58
- - PLAN-009: workflow-ai 1.2 — manual-gate stage and approval files for workflow-mcp Sprint 2 integration
59
- - IMPL-55, IMPL-56, IMPL-57, IMPL-58: Implementation tickets
60
- - QA-35, QA-36, QA-37: Test coverage
61
- - IMPL-51, QA-55 in workflow-mcp: Dependent consumer work
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
+
13
+ ## [1.3.0] 2026-04-30
14
+
15
+ ### Добавлено
16
+ - **Новый тип стейджа: `mark-blocked` и расширение frontmatter** Добавлены поля `auto_blocked_reason`, `auto_blocked_attempts`, `auto_blocked_at` для автоматического отслеживания причин и попыток блокировки тикетов. Стейдж `mark-blocked` позволяет устанавливать эти поля в зависимости от условий бизнес-логики.
17
+ - **Новый тип стейджа: `manual-gate-human`** — Добавлен статус `human_ready` в `pick-next-task.js`. При достижении этого статуса задача ожидает ручного решения оператора перед продолжением выполнения.
18
+ - **Хук одобрения в `move-ticket.js`** — Добавлен approval-hook, проверяющий наличие ожидающих решения одобрений перед перемещением тикета в терминальные состояния.
19
+ - **Расширение START-лога полем `ticket="X"`** Добавлено поле с идентификатором тикета в стартовый лог для улучшенной трассировки и связывания логов с конкретными задачами.
20
+ - **Исправление `approval-pending.mjs` (поле `created_at`)** Исправлено некорректное заполнение поля `created_at` при создании файлов ожидающих решения.
21
+
22
+ > **Примечание:** версия 1.2.0 не была опубликована в npm. При выполнении `npm publish`
23
+ > сработал prepublishOnly/postversion скрипт, автоматически поднявший версию до 1.2.1.
24
+ > Git-тег `v1.2.0` (коммит 179d52b) соответствует состоянию до publish; `v1.2.1` (коммит 83d1b70)
25
+ > фактически опубликованной версии.
26
+
27
+ ### New Features
28
+ - **New built-in stage type: `manual-gate`** — Adds support for manual approval steps in pipelines. When a stage with `type: manual-gate` is encountered, the runner creates a pending approval file in `.workflow/approvals/{step_id}.json` and enters a polling loop, waiting for an external decision (`approved`/`rejected`).
29
+
30
+ **Key capabilities:**
31
+ - Deterministic `step_id` generation: `{ticket_id}_{stageId}_{attempt}` (e.g., `QA-12_manual-approve_0`)
32
+ - Idempotent file creation — pending file is not overwritten on retry/restart
33
+ - Configurable polling interval (`poll_interval_ms`, default 2000ms) and optional timeout (`timeout_seconds`)
34
+ - Graceful handling of SIGTERM/runner stop (returns `aborted`)
35
+ - Immediate return if file already has `approved`/`rejected` status (crash recovery)
36
+ - JSON approval file format with full audit trail (`created_at`, `updated_at`, `decided_by`, `comment`, `context_snapshot`)
37
+
38
+ **Pipeline configuration example:**
39
+ ```yaml
40
+ stages:
41
+ manual-approve-deploy:
42
+ type: manual-gate
43
+ poll_interval_ms: 2000
44
+ timeout_seconds: 86400
45
+ goto:
46
+ approved: continue-deploy
47
+ rejected: rollback
48
+ timeout: notify-stuck
49
+ aborted: end
50
+ ```
51
+
52
+ **Two approval methods (both opt-in):**
53
+ 1. **External MCP/client**: tools like `workflow-mcp` can write to approval files programmatically
54
+ 2. **Direct file edit**: users can simply edit `.workflow/approvals/{step_id}.json` and change `status` to `approved` or `rejected`
55
+
56
+ **Important:** `manual-gate` is **opt-in** — pipelines without such stages work identically to previous versions. No breaking changes.
57
+
58
+ ### Changed
59
+ - No breaking changes. All existing pipelines without `manual-gate` stages are fully backward compatible.
60
+
61
+ ### Fixes
62
+ - Исправлено сохранение временной метки `created_at` в файлах одобрений (approval-pending.mjs)
63
+
64
+ ### Technical Notes
65
+ - Approval files are stored in `<project_root>/.workflow/approvals/`
66
+ - Runner validates `manual-gate` stages on startup — requires `goto.approved` and `goto.rejected`, validates numeric parameters
67
+ - New methods added to `PipelineRunner`: `computeStepId()`, `writeApprovalPending()`, `readApprovalFile()`, `executeManualGate()`
68
+
69
+ ### References
70
+ - PLAN-009: workflow-ai 1.2 — manual-gate stage and approval files for workflow-mcp Sprint 2 integration
71
+ - IMPL-55, IMPL-56, IMPL-57, IMPL-58: Implementation tickets
72
+ - QA-35, QA-36, QA-37: Test coverage
73
+ - IMPL-51, QA-55 in workflow-mcp: Dependent consumer work
package/README.md CHANGED
@@ -2,6 +2,33 @@
2
2
  - Добавлено упоминание `human-gate` в разделе про runner-стадии (пример конфига в pipeline.yaml: human-review-step типа manual-gate).
3
3
  - Добавлено упоминание `mark-blocked` как механизм нотификации autoblocked-тикетов (поля auto_blocked_reason/attempts/at).
4
4
 
5
+ ## Синглтон семантика Pipeline
6
+
7
+ Система workflow реализует паттерн синглтона для выполнения пайплайна: одновременно разрешена только одна активная инстанция пайплайна для каждого проекта.
8
+
9
+ ### Семантика Синглтона
10
+ При попытке запустить второй пайплайн, когда первый уже работает, система вернёт ошибку `PIPELINE_ALREADY_RUNNING`.
11
+
12
+ ### Структура `.workflow/logs/.pipeline.lock`
13
+ Файл блокировки содержит следующие поля:
14
+ - `pid`: ID процесса работающего пайплайна
15
+ - `started_at`: Временная метка запуска пайплайна
16
+ - `started_by`: Инициатор запуска пайплайна (cli | mcp | extension)
17
+ - `run_id`: Уникальный идентификатор этого запуска
18
+ - `pipeline_log`: Путь к файлу логов пайплайна
19
+ - `project_root`: Корневая директория проекта
20
+ - `pipeline_version`: Версия выполняемого пайплайна
21
+
22
+ ### Команды
23
+ - `workflow run --project <path> [--started-by cli|mcp|extension] [--force]` - Запустить пайплайн для указанного проекта
24
+ - `workflow stop --project <path> [--grace-sec N]` - Мягко остановить пайплайн (SIGTERM → SIGKILL после N секунд)
25
+ - `--force` флаг - Обход блокировки (только для отладки, когда процесс зависает)
26
+
27
+ ### Восстановление
28
+ Если файл блокировки устарел (процесс упал, но блокировка не удалена):
29
+ 1. Проверь, что процесс мёртв: `workflow stop --project <path>` (автоматически детектит устаревшие блокировки)
30
+ 2. Если стандартная остановка не решает проблему: `workflow run --force --project <path>`
31
+
5
32
  ## Runner-стадии
6
33
 
7
34
  В системе поддерживаются различные типы стадий для управления процессами разработки:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workflow-ai",
3
- "version": "1.3.0",
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": {
@@ -0,0 +1,108 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ const MARKER_FILE = '.pipeline.lock';
5
+ const LOGS_DIR = '.workflow/logs';
6
+
7
+ /**
8
+ * Ensures the logs directory exists
9
+ */
10
+ function ensureLogsDir(projectRoot) {
11
+ const logsPath = path.join(projectRoot, LOGS_DIR);
12
+ if (!fs.existsSync(logsPath)) {
13
+ fs.mkdirSync(logsPath, { recursive: true });
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Atomic write via temp file + rename.
19
+ * Fallback to O_EXCL (wx flag) on EXDEV/ENOTSUP.
20
+ * Throws Error if marker file already exists (to prevent race conditions).
21
+ */
22
+ export function writeMarker(projectRoot, payload) {
23
+ ensureLogsDir(projectRoot);
24
+ const markerPath = path.join(projectRoot, LOGS_DIR, MARKER_FILE);
25
+ const content = JSON.stringify(payload, null, 2);
26
+
27
+ // First, try exclusive create (O_EXCL) - this guarantees atomicity
28
+ // and prevents race conditions where two processes both think they created the marker
29
+ try {
30
+ const fd = fs.openSync(markerPath, 'wx');
31
+ fs.writeFileSync(fd, content, 'utf-8');
32
+ fs.closeSync(fd);
33
+ return;
34
+ } catch (openErr) {
35
+ if (openErr.code === 'EEXIST') {
36
+ // Marker already exists - another process created it
37
+ throw new Error(`Marker file already exists at ${markerPath}`);
38
+ }
39
+ // If EXDEV/ENOTSUP (cross-device link), fall back to temp+rename
40
+ if (openErr.code !== 'EXDEV' && openErr.code !== 'ENOTSUP') {
41
+ throw openErr;
42
+ }
43
+ }
44
+
45
+ // Fallback: temp file + rename (less atomic but works across devices)
46
+ const tempPath = markerPath + '.tmp.' + process.pid + '.' + Date.now();
47
+ try {
48
+ fs.writeFileSync(tempPath, content, 'utf-8');
49
+ fs.renameSync(tempPath, markerPath);
50
+ return;
51
+ } catch (renameErr) {
52
+ // Cleanup temp file if it still exists
53
+ try {
54
+ fs.unlinkSync(tempPath);
55
+ } catch {
56
+ // ignore
57
+ }
58
+
59
+ // If rename failed because marker was created by another process in the meantime
60
+ if (renameErr.code === 'EEXIST' || !fs.existsSync(tempPath)) {
61
+ throw new Error(`Marker file already exists at ${markerPath}`);
62
+ }
63
+
64
+ throw renameErr;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Reads and parses marker file. Returns null if file doesn't exist or is invalid.
70
+ */
71
+ export function readMarker(projectRoot) {
72
+ const markerPath = path.join(projectRoot, LOGS_DIR, MARKER_FILE);
73
+ try {
74
+ const data = fs.readFileSync(markerPath, 'utf-8');
75
+ return JSON.parse(data);
76
+ } catch (err) {
77
+ // ENOENT (file missing) or SyntaxError (invalid JSON) → return null
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Silently removes marker file. Ignores ENOENT.
84
+ */
85
+ export function removeMarker(projectRoot) {
86
+ const markerPath = path.join(projectRoot, LOGS_DIR, MARKER_FILE);
87
+ try {
88
+ fs.unlinkSync(markerPath);
89
+ } catch (err) {
90
+ // Ignore ENOENT — file already gone
91
+ if (err.code !== 'ENOENT') {
92
+ // Log warning but don't throw — silent unlink
93
+ console.warn(`[marker] failed to remove ${markerPath}: ${err.message}`);
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Validates that marker exists and its pid matches expectedPid.
100
+ * Returns true only if both conditions hold, false otherwise.
101
+ */
102
+ export function validateMarker(projectRoot, expectedPid) {
103
+ const marker = readMarker(projectRoot);
104
+ if (!marker) {
105
+ return false;
106
+ }
107
+ return marker.pid === expectedPid;
108
+ }
@@ -0,0 +1,11 @@
1
+ export function processAlive(pid) {
2
+ if (!Number.isInteger(pid) || pid <= 0) return false;
3
+ try {
4
+ process.kill(pid, 0); // signal 0: existence check, не убивает процесс
5
+ return true;
6
+ } catch (err) {
7
+ // ESRCH = no process; EPERM = process exists but no permission
8
+ if (err.code === 'EPERM') return true;
9
+ return false;
10
+ }
11
+ }
@@ -0,0 +1,82 @@
1
+ import { readMarker, removeMarker } from './marker.mjs';
2
+ import { processAlive } from './process-alive.mjs';
3
+ import { execSync } from 'node:child_process';
4
+ import { existsSync } from 'node:fs';
5
+
6
+ /**
7
+ * Sleep helper
8
+ */
9
+ function sleep(ms) {
10
+ return new Promise(resolve => setTimeout(resolve, ms));
11
+ }
12
+
13
+ /**
14
+ * Gracefully stops a running pipeline.
15
+ *
16
+ * @param {string} projectRoot - Absolute path to project root
17
+ * @param {object} options - Options
18
+ * @param {number} [options.graceSec=10] - Grace period in seconds before SIGKILL
19
+ * @returns {object} Result object
20
+ * - { ok: true, pid: number, escalated: boolean, duration_ms: number } on success
21
+ * - { ok: true, was_stale: true } if marker existed but process was dead
22
+ * - { ok: false, code: 'NOT_RUNNING' } if no marker found
23
+ */
24
+ export async function stopPipeline(projectRoot, options = {}) {
25
+ const graceSec = options.graceSec ?? 10;
26
+ const marker = readMarker(projectRoot);
27
+
28
+ if (!marker) {
29
+ return { ok: false, code: 'NOT_RUNNING' };
30
+ }
31
+
32
+ if (!processAlive(marker.pid)) {
33
+ removeMarker(projectRoot);
34
+ return { ok: true, was_stale: true };
35
+ }
36
+
37
+ const startMs = Date.now();
38
+ const isWindows = process.platform === 'win32';
39
+
40
+ if (isWindows) {
41
+ // taskkill /T /F /PID <pid> — kills process tree immediately (forceful)
42
+ // No graceful period on Windows — taskkill /T does tree kill in one shot
43
+ try {
44
+ execSync(`taskkill /T /F /PID ${marker.pid}`, { stdio: 'ignore' });
45
+ } catch (err) {
46
+ // Ignore errors — process may have exited already
47
+ }
48
+ } else {
49
+ // POSIX: send SIGTERM, wait, then escalate to SIGKILL if needed
50
+ try {
51
+ process.kill(marker.pid, 'SIGTERM');
52
+ } catch (err) {
53
+ // Process may have exited between check and kill
54
+ }
55
+
56
+ let escalated = false;
57
+ const deadline = Date.now() + graceSec * 1000;
58
+
59
+ while (processAlive(marker.pid)) {
60
+ if (Date.now() >= deadline) {
61
+ try {
62
+ process.kill(marker.pid, 'SIGKILL');
63
+ escalated = true;
64
+ } catch (err) {
65
+ // Process may have already exited
66
+ }
67
+ break;
68
+ }
69
+ await sleep(200);
70
+ }
71
+ }
72
+
73
+ // Ensure marker is removed
74
+ removeMarker(projectRoot);
75
+
76
+ return {
77
+ ok: true,
78
+ pid: marker.pid,
79
+ escalated: isWindows ? false : (escalated || false),
80
+ duration_ms: Date.now() - startMs
81
+ };
82
+ }
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
@@ -9,6 +9,94 @@ import { findProjectRoot } from './lib/find-root.mjs';
9
9
  import { loadRules, scanStderrForFatalRule, classify } from './lib/error-classifier.mjs';
10
10
  import { snapshot, diff, isEmpty } from './lib/artifact-snapshot.mjs';
11
11
  import { markUnhealthy, isHealthy } from './lib/agent-health-registry.mjs';
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
+ }
12
100
 
13
101
  // ============================================================================
14
102
  // Logger — система логирования с уровнями DEBUG/INFO/WARN/ERROR
@@ -1040,6 +1128,29 @@ class StageExecutor {
1040
1128
 
1041
1129
  const result = await this.callAgent(agent, prompt, stageId, effectiveStage.skill, agentId);
1042
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
+
1043
1154
  if (this.logger) this.logger.stageComplete(stageId, result.status, result.exitCode);
1044
1155
  return result;
1045
1156
  } catch (err) {
@@ -1048,6 +1159,16 @@ class StageExecutor {
1048
1159
  const exitCode = err.exitCode ?? err.code;
1049
1160
  const stderr = err.stderr || '';
1050
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
+
1051
1172
  const after = snapshotEnabled ? await snapshot(this.projectRoot, snapshotOpts) : null;
1052
1173
  const diffResult = snapshotEnabled ? diff(before, after) : null;
1053
1174
  const diffEmpty = snapshotEnabled && isEmpty(diffResult);
@@ -1087,6 +1208,68 @@ class StageExecutor {
1087
1208
  }
1088
1209
  }
1089
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
+
1090
1273
  /**
1091
1274
  * Выполняет stage через выбранного CLI-агента (новая модель выбора).
1092
1275
  * @param {string} stageId - ID stage из конфигурации
@@ -1196,7 +1379,10 @@ class StageExecutor {
1196
1379
  if (this.logger) {
1197
1380
  this.logger.timeout(stageId, timeout);
1198
1381
  }
1199
- 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);
1200
1386
  }, timeout * 1000);
1201
1387
 
1202
1388
  let stdoutBuffer = '';
@@ -1270,7 +1456,7 @@ class StageExecutor {
1270
1456
  reject(err);
1271
1457
  });
1272
1458
 
1273
- child.on('close', (code) => {
1459
+ child.on('close', (code, signal) => {
1274
1460
  this.currentChild = null;
1275
1461
  clearTimeout(timeoutId);
1276
1462
  // Обрабатываем остаток буфера стриминга
@@ -1339,6 +1525,7 @@ class StageExecutor {
1339
1525
  err.code = 'NON_ZERO_EXIT';
1340
1526
  err.exitCode = code;
1341
1527
  err.stderr = stderr;
1528
+ err.signal = signal;
1342
1529
  if (this.logger) {
1343
1530
  this.logger.error(`Agent exited with code ${code}`, stageId);
1344
1531
  if (stderr.trim()) {
@@ -1564,7 +1751,7 @@ class PipelineRunner {
1564
1751
  }
1565
1752
  throw err;
1566
1753
  }
1567
- }
1754
+ }
1568
1755
 
1569
1756
  /**
1570
1757
  * Читает approval-файл и парсит его как JSON.
@@ -1587,7 +1774,7 @@ class PipelineRunner {
1587
1774
  // Перебрасываем другие ошибки (например, fs-ошибки) без изменений
1588
1775
  throw err;
1589
1776
  }
1590
- }
1777
+ }
1591
1778
 
1592
1779
  /**
1593
1780
  * Выполняет встроенный стейдж типа update-counter:
@@ -1756,7 +1943,7 @@ class PipelineRunner {
1756
1943
  this.logger.warn(`[${stageId}] manual-gate: aborted (runner stopped)`, stageId);
1757
1944
  }
1758
1945
  return { status: 'aborted', result: { step_id: stepId } };
1759
- }
1946
+ }
1760
1947
 
1761
1948
  /**
1762
1949
  * Запускает основной цикл выполнения
@@ -1802,7 +1989,7 @@ class PipelineRunner {
1802
1989
  this.currentExecutor = new StageExecutor(this.config, this.context, this.counters, {}, this.fileGuard, this.logger, this.projectRoot);
1803
1990
  result = await this.currentExecutor.execute(this.currentStage);
1804
1991
  this.currentExecutor = null;
1805
- }
1992
+ }
1806
1993
 
1807
1994
  this.logger.info(`Stage ${this.currentStage} completed with status: ${result.status}`, 'PipelineRunner');
1808
1995
 
@@ -2146,18 +2333,40 @@ async function runPipeline(argv = process.argv.slice(2)) {
2146
2333
  return { exitCode: 0, help: true };
2147
2334
  }
2148
2335
 
2149
- // Resolve config path
2336
+ // Resolve config path and projectRoot
2150
2337
  if (!args.config) {
2151
2338
  const projectRoot = args.project ? path.resolve(args.project) : findProjectRoot();
2152
2339
  args.config = path.resolve(projectRoot, '.workflow/config/pipeline.yaml');
2340
+ args.projectRoot = projectRoot;
2153
2341
  }
2154
2342
 
2343
+ const projectRoot = args.projectRoot || (args.project ? path.resolve(args.project) : findProjectRoot());
2344
+
2155
2345
  console.log('=== Workflow Runner ===');
2156
2346
  console.log(`Config: ${args.config}`);
2157
2347
  if (args.plan) console.log(`Plan: ${args.plan}`);
2158
2348
  if (args.project) console.log(`Project: ${args.project}`);
2159
2349
  console.log('');
2160
2350
 
2351
+ // Write marker to protect against stale processes
2352
+ try {
2353
+ writeMarker(projectRoot, {
2354
+ pid: process.pid,
2355
+ timestamp: new Date().toISOString()
2356
+ });
2357
+ } catch (err) {
2358
+ console.error(`[runner] failed to write marker: ${err.message}`);
2359
+ return { exitCode: 1, error: 'Failed to acquire pipeline lock', details: err.message };
2360
+ }
2361
+
2362
+ // Register signal handlers for cleanup
2363
+ const cleanup = () => {
2364
+ removeMarker(projectRoot);
2365
+ process.exit(130); // 128 + SIGINT(2) — standard exit code for signal-terminated
2366
+ };
2367
+ process.once('SIGINT', cleanup);
2368
+ process.once('SIGTERM', cleanup);
2369
+
2161
2370
  try {
2162
2371
  const config = loadConfig(args.config);
2163
2372
  const errors = validateConfig(config);
@@ -2192,6 +2401,16 @@ async function runPipeline(argv = process.argv.slice(2)) {
2192
2401
  await new Promise(resolve => setTimeout(resolve, 100));
2193
2402
 
2194
2403
  return { exitCode: 1, error: err.message, stack: err.stack };
2404
+ } finally {
2405
+ // Ensure marker is cleaned up on normal exit
2406
+ try {
2407
+ removeMarker(projectRoot);
2408
+ } catch (err) {
2409
+ // ENOENT is expected, log warnings for other errors
2410
+ if (err.code && err.code !== 'ENOENT') {
2411
+ console.warn(`[runner] cleanup warning: ${err.message}`);
2412
+ }
2413
+ }
2195
2414
  }
2196
2415
  }
2197
2416
 
@@ -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() {
@@ -85,7 +85,7 @@ function readPrefixesFromConfig() {
85
85
  throw new Error(`config.yaml not found: ${configPath}`);
86
86
  }
87
87
 
88
- const text = fs.readFileSync(configPath, "utf8");
88
+ const text = fs.readFileSync(configPath, "utf8");
89
89
  const lines = text.split(/\r?\n/);
90
90
 
91
91
  const prefixes = [];
@@ -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
 
@@ -128,6 +128,7 @@ ticket_prefix: COACH
128
128
  **⚠️ Антипаттерн «уход в формулировки вместо root cause»:** стейкхолдер задаёт вопрос о наблюдаемом дефекте («почему не поймали?»), а коуч анализирует текст формулировок, семантику переносов, чеклисты — вместо того чтобы ответить на прямой вопрос: какой конкретный шаг в какой конкретной стадии не выполнил конкретное физическое действие (открыть файл, посмотреть на картинку, запустить команду). Формулировки — это причина второго порядка; причина первого порядка — «агент X не сделал действие Y». Всегда начинай с причины первого порядка, потом объясняй, почему инструкции это допустили.
129
129
  **⚠️ Антипаттерн «оценка по результату вместо сверки с инструкцией»:** при анализе действия агента — **не оценивай** его «разумность» или «допустимость» по своему суждению. Вместо этого открой скил агента и **дословно сверь** действие с инструкцией. Если инструкция говорит «разбей тикет», а агент объединил шаги — это нарушение, даже если результат выглядит «приемлемо». Коуч не имеет права смягчать finding на основании того, что дефект «небольшой» или «единичный» — скил либо нарушен, либо нет.
130
130
  **⚠️ Антипаттерн «пересказ вместо цитаты» при утверждениях о коде:** перед утверждением вида «скрипт/функция X использует/читает/пишет Y» обязан открыть файл и **дословно процитировать** строку, на которой это поведение происходит. Пересказ по памяти (даже свежей) теряет операторы-fallback (`a || b`, `a ?? b`), условные ветви, ранние return'ы — те детали, которые как раз и задают реальное поведение. Источник ошибки: агент видит ключевое слово в строке, строит «достаточное» утверждение о поведении и идёт дальше. Правило: если утверждение про код войдёт в финдинг, CHG, черновик правки или ответ стейкхолдеру — строка должна быть в отчёте целиком (либо скопированной в цитату, либо явной ссылкой `file:line`, открытой и перечитанной непосредственно перед утверждением).
131
+ **⚠️ Антипаттерн «отрицательное утверждение о capability инструмента без проверки» (расширение предыдущего):** правило «дословная цитата» применяется не только к **позитивным** утверждениям («X делает Y»), но и к **отрицательным** («инструмент/фреймворк/тест X **не умеет / не покрывает / не подходит для** Y»). Отрицательное утверждение о capability — такое же утверждение о коде, как и позитивное, и требует тех же доказательств: либо (1) Grep по тестам/коду проекта, использующим инструмент, на ключевой паттерн использования (например, для гипотезы «Playwright не покрывает extension popup» — `Grep "loadExtension|launchPersistentContext|chrome-extension://"` по `tests/`), либо (2) дословная цитата из официальной документации. Источник ошибки: агент экстраполирует «общеизвестное» назначение инструмента (Playwright = веб-сайты, jest = unit-тесты и т.п.) без проверки фактической практики проекта или актуальных возможностей. Триггер срабатывания: если в черновике финдинга/ответа есть конструкция «{инструмент} не {глагол} {объект}» и от неё зависит root cause или CHG — **обязательна** проверка перед показом стейкхолдеру. Без проверки финдинг невалиден, даже если интуиция кажется правильной.
131
132
  3. **Итеративность** — улучшай скилы инкрементально. Маленькие точечные улучшения > масштабные переписывания.
132
133
  4. **Обратная совместимость** — улучшения не должны ломать существующие воркфлоу и интеграции.
133
134
  5. **Актуальность знаний** — активно ищи в интернете лучшие практики, фреймворки и подходы для обогащения скилов.
@@ -70,6 +70,14 @@ description: >
70
70
  |----------|----------------|
71
71
  | `algorithms/execution-strategy.md` | **ВСЕГДА** — стратегия анализа, выполнения и верификации задачи |
72
72
 
73
+ ## Загрузка шаблонов
74
+
75
+ Подгружай из `templates/` при необходимости:
76
+
77
+ | Шаблон | Когда загружать |
78
+ |--------|----------------|
79
+ | `templates/result-template.md` | При создании секции `## Result` в тикете — структура и правила заполнения |
80
+
73
81
  ## Шаги выполнения
74
82
 
75
83
  ### 1. Прочитать тикет
@@ -122,6 +130,8 @@ description: >
122
130
  3. Прочитать `context.notes` — дополнительный контекст от создателя тикета
123
131
  4. Если тикет ссылается на план (`parent_plan`) — прочитать план для понимания общей картины
124
132
 
133
+ **⛔ Валидация путей перед Edit:** если DoD требует изменить конкретный файл, указанный в `context.files` или описании, используй **ровно тот путь**, который указан. Перед каждым Edit сверь целевой путь с путём из контекста тикета. Одноимённый файл в другой директории — не целевой файл. Пример нарушения: тикет указывает `workflow-ai/README.md`, агент редактирует `./README.md`.
134
+
125
135
  ### 5. Выполнить работу и фиксировать результат инкрементально
126
136
 
127
137
  Действовать по описанию и DoD тикета. Подход определяется **содержимым тикета**, а не типом:
@@ -131,6 +141,8 @@ description: >
131
141
  - Если тикет требует тестирования — выполнить чеклист проверок из DoD, зафиксировать pass/fail по каждому пункту
132
142
  - Если тикет требует исследования — использовать доступные инструменты для сбора данных, подкреплять источниками
133
143
 
144
+ **⛔ Перед вставкой кода в существующий файл — прочитай целевой участок и пойми его структуру.** Определи границы функции/класса/блока, в который вставляешь код. Вставляй код в семантически правильное место, а не в произвольную точку файла. Если не уверен в месте вставки — прочитай окружающий контекст (строки до и после) и убедись, что вставка не разрывает существующую структуру.
145
+
134
146
  **⚠️ ИНКРЕМЕНТАЛЬНАЯ ЗАПИСЬ (ОБЯЗАТЕЛЬНО):**
135
147
 
136
148
  После выполнения **каждого пункта** — **сразу** запиши результат в тикет:
@@ -156,7 +168,7 @@ description: >
156
168
 
157
169
  К этому моменту секция Result уже содержит результаты по каждому пункту (записаны инкрементально на шаге 5). Осталось:
158
170
 
159
- - Обновить/добавить **Summary** — краткое резюме всей работы
171
+ - Обновить/добавить **Что сделано** — краткое резюме всей работы
160
172
  - Дополнить **Изменённые файлы** и **Заметки** если нужно
161
173
  - **НЕ удалять и не переписывать** уже записанные результаты
162
174
 
@@ -191,6 +203,14 @@ description: >
191
203
 
192
204
  ### 9. Вывести структурированный результат
193
205
 
206
+ **⛔ Перед выводом RESULT пройди все три GATE из `workflows/execute.md` (шаг 6).** GATE-1 (Edit-проверка), GATE-2 (механическая Read-проверка), GATE-3 (self-check). При любом нарушении — вернись к шагам 5–7.
207
+
208
+ ⛔ **GATE-1 — EDIT-ПРОВЕРКА (выполни ПЕРЕД Read-проверкой):**
209
+
210
+ За текущую сессию ты должен был вызвать инструмент **`Edit`** на файл тикета как минимум дважды: один раз для обновления DoD-чекбоксов (`[ ]` → `[x]`), один раз для записи секции Result. Если ни одного вызова `Edit` на файл тикета `.workflow/tickets/in-progress/{TICKET-ID}.md` не было — секция Result физически пустая и DoD не отмечен, независимо от написанного в stdout. Это призрачное выполнение (ограничение #9). Немедленно вернись к шагам 5–7.
211
+
212
+ **Ключевой принцип:** stdout (текст ответа) ≠ файл тикета. Результат существует только в том, что записано через инструмент `Edit`.
213
+
194
214
  **⛔ ОБЯЗАТЕЛЬНАЯ МЕХАНИЧЕСКАЯ ПРОВЕРКА — перечитай файл тикета перед RESULT:**
195
215
 
196
216
  Перед выводом `---RESULT---` выполни `Read` на файл тикета (`.workflow/tickets/in-progress/{TICKET-ID}.md`) и глазами убедись:
@@ -219,6 +239,7 @@ description: >
219
239
  - Нет побочных эффектов — не созданы тикеты/планы, не перемещены файлы
220
240
  - Поля `status` и `completed_at` не записаны в файл тикета ни в каком виде
221
241
  - Секция `## Ревью` не создавалась и не редактировалась тобой
242
+ - Все временные/промежуточные файлы и пустые директории, созданные в ходе работы и не являющиеся deliverable, удалены; файлы, путь которых зависит от конфига инструмента (mock/test/fixture/snapshot/output), лежат в директории, указанной в конфиге, а не в произвольном месте
222
243
 
223
244
  **⛔ ФОРМАТ STDOUT — СТРОГО:**
224
245
 
@@ -273,6 +294,12 @@ status: default
273
294
 
274
295
  При визуальных/семантических/поведенческих критериях в Result **явно обоснуй**, почему структурной проверки недостаточно (одна строка). Полная таблица соответствий — `algorithms/execution-strategy.md` раздел «Соразмерность проверки критерию».
275
296
 
297
+ 9. **Configured paths и cleanup** — файлы, создаваемые во время работы тикета, размещай в местах, явно указанных в конфиге соответствующего инструмента. Перед созданием файла, путь которого может зависеть от конфигурации (mock, тест, фикстура, стаб, snapshot, сгенерированный artifact, временный файл сборки и подобное) — **прочитай соответствующий конфиг проекта**, найди в нём поле target-директории (`roots`, `testDir`, `paths`, `output`, `outDir`, `srcDir` или аналог в конфиге твоего инструмента) и помести файл туда. Создавать файл в корне репозитория без проверки конфига запрещено.
298
+
299
+ **После удаления временных/ошибочных файлов — проверь и удали пустые родительские директории**, созданные в ходе тикета. Каждая созданная директория — твоя ответственность вплоть до закрытия тикета. Пустая папка или артефакт вне scope, оставленные в репозитории, — побочный эффект (см. принцип 4 «No Side Effects»).
300
+
301
+ ⛔ **Антипаттерн:** создал файл в корне (mock/snapshot/test) → инструмент не подхватил из-за неправильного пути → удалил файл, но оставил пустую папку. Лечение: до создания — проверка конфига; после rm — удаление пустых директорий.
302
+
276
303
  ## Формат вывода
277
304
 
278
305
  - Русский язык
@@ -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
  > **Порядок записей:** хронологический сверху вниз. Последняя строка = последнее ревью.