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 +73 -61
- package/README.md +27 -0
- package/package.json +1 -1
- package/src/lib/marker.mjs +108 -0
- package/src/lib/process-alive.mjs +11 -0
- package/src/lib/stop-command.mjs +82 -0
- package/src/lib/utils.mjs +1 -72
- package/src/runner.mjs +226 -7
- package/src/scripts/check-relevance.js +11 -35
- package/src/scripts/get-next-id.js +1 -1
- package/src/scripts/move-ticket.js +8 -1
- package/src/skills/coach/SKILL.md +1 -0
- package/src/skills/execute-task/SKILL.md +28 -1
- package/src/skills/review-result/SKILL.md +8 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,61 +1,73 @@
|
|
|
1
|
-
## [1.
|
|
2
|
-
|
|
3
|
-
###
|
|
4
|
-
-
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
###
|
|
16
|
-
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
|
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() {
|
|
@@ -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
|
|
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
|
-
- Обновить/добавить
|
|
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
|
> **Порядок записей:** хронологический сверху вниз. Последняя строка = последнее ревью.
|