workflow-ai 1.0.54 → 1.0.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,6 +52,7 @@ The `workflow init` command creates the `.workflow/` directory structure:
52
52
  │ └── ticket-movement-rules.yaml
53
53
  ├── plans/
54
54
  │ ├── current/ # Current development plans
55
+ │ ├── templates/ # Plan templates with triggers (recurring plans)
55
56
  │ └── archive/ # Archived plans
56
57
  ├── tickets/
57
58
  │ ├── backlog/ # Awaiting conditions
@@ -74,20 +75,21 @@ The `workflow init` command creates the `.workflow/` directory structure:
74
75
  The `workflow run` command executes a multi-stage pipeline:
75
76
 
76
77
  1. **pick-first-task** — select ticket from ready queue
77
- 2. **check-plan-decomposition** — verify plan is decomposed into tickets
78
- 3. **decompose-plan** — break down plan into tickets (if needed)
79
- 4. **check-conditions** — validate ticket readiness conditions
80
- 5. **move-to-ready** — move tickets from backlog to ready
81
- 6. **pick-next-task** — select next ticket for execution
82
- 7. **move-to-in-progress** — start execution
83
- 8. **check-relevance** — verify ticket is still relevant
84
- 9. **execute-task** — perform the work via AI agent
85
- 10. **move-to-review** — submit for review
86
- 11. **review-result** — validate results against Definition of Done
87
- 12. **increment-task-attempts** — track retry attempts
88
- 13. **move-ticket** — move to done/blocked based on review
89
- 14. **create-report** — generate execution report
90
- 15. **analyze-report / decompose-gaps** — analyze results and iterate
78
+ 2. **check-plan-templates** — evaluate plan template triggers, create plans if fired
79
+ 3. **check-plan-decomposition** — verify plan is decomposed into tickets
80
+ 4. **decompose-plan** — break down plan into tickets (if needed)
81
+ 5. **check-conditions** — validate ticket readiness conditions
82
+ 6. **move-to-ready** — move tickets from backlog to ready
83
+ 7. **pick-next-task** — select next ticket for execution
84
+ 8. **move-to-in-progress** — start execution
85
+ 9. **check-relevance** — verify ticket is still relevant
86
+ 10. **execute-task** — perform the work via AI agent
87
+ 11. **move-to-review** — submit for review
88
+ 12. **review-result** — validate results against Definition of Done
89
+ 13. **increment-task-attempts** — track retry attempts
90
+ 14. **move-ticket** — move to done/blocked based on review
91
+ 15. **create-report** — generate execution report
92
+ 16. **analyze-report / decompose-gaps** — analyze results and iterate
91
93
 
92
94
  ### Supported Agents
93
95
 
@@ -121,6 +123,34 @@ Skills are stored globally in `~/.workflow/skills/` and symlinked into projects.
121
123
 
122
124
  Use `workflow eject <skill>` to copy a skill into the project for customization.
123
125
 
126
+ ## Plan Templates
127
+
128
+ Plan templates allow recurring plans to be created automatically. Templates live in `.workflow/plans/templates/` and contain trigger conditions in their frontmatter.
129
+
130
+ ### Template Format
131
+
132
+ ```yaml
133
+ id: "TMPL-001"
134
+ title: "Daily manual testing"
135
+ type: template
136
+ trigger:
137
+ type: daily # daily | weekly | date_after | interval_days
138
+ params: {} # type-specific params
139
+ last_triggered: "" # auto-updated on trigger
140
+ enabled: true
141
+ ```
142
+
143
+ ### Trigger Types
144
+
145
+ | Type | Params | Description |
146
+ |------|--------|-------------|
147
+ | `daily` | — | Once per day |
148
+ | `weekly` | `days_of_week: [1,3,5]` (0=Sun) | On specific weekdays |
149
+ | `date_after` | `date: "2026-04-01"` | Once after a specific date |
150
+ | `interval_days` | `days: 3` | Every N days |
151
+
152
+ When a trigger fires, the pipeline creates a plan in `plans/current/` with status `approved`, then the normal decomposition flow proceeds.
153
+
124
154
  ## Task Types
125
155
 
126
156
  | Type | Prefix | Description |
@@ -144,6 +144,9 @@ pipeline:
144
144
  # counter: <string> — имя счётчика попыток для этого stage
145
145
  # agent_by_type: — маршрутизация агента по типу задачи (task_type)
146
146
  # <type>: <agent-id> — например: impl: claude-sonnet, arch: claude-opus
147
+ # <type>: — или расширенный формат с fallback_model:
148
+ # agent: <agent-id>
149
+ # fallback_model: <agent-id>
147
150
  # agent_by_attempt: — ротация агента по номеру попытки
148
151
  # <N>: <agent-id> — например: 1: qwen-code, 2: claude-sonnet
149
152
  # goto: — логика переходов по результату
@@ -348,14 +351,21 @@ pipeline:
348
351
  skill: execute-task
349
352
  counter: task_attempts
350
353
  agent_by_type:
351
- coach: claude-coach
352
- qa: claude-qa
353
- impl: kilo-minimax
354
- # fix: claude-sonnet
355
- arch: claude-opus
356
- # review: qwen-code
357
- # docs: qwen-code
358
- admin: kilo-minimax
354
+ coach:
355
+ agent: claude-coach
356
+ fallback_model: claude-sonnet
357
+ qa:
358
+ agent: claude-qa
359
+ fallback_model: claude-sonnet
360
+ impl:
361
+ agent: kilo-minimax
362
+ fallback_model: kilo-glm
363
+ arch:
364
+ agent: claude-opus
365
+ fallback_model: claude-sonnet
366
+ admin:
367
+ agent: kilo-minimax
368
+ fallback_model: kilo-glm
359
369
  agent_by_attempt:
360
370
  2: qwen-code
361
371
  3: claude-sonnet
@@ -543,6 +553,15 @@ pipeline:
543
553
  - ".workflow/plans/**" # Планы — только для чтения
544
554
  - ".workflow/config/**" # Конфигурация
545
555
 
556
+ # ===========================================================================
557
+ # Доверенные агенты (trusted_agents)
558
+ # ===========================================================================
559
+ # Агенты, для которых FileGuard не откатывает изменения в protected_files.
560
+ # Поддерживает glob-паттерны: "script-*" соответствует "script-move" и т.д.
561
+ # ===========================================================================
562
+ trusted_agents:
563
+ - "script-check-templates" # Создаёт планы в plans/current/
564
+
546
565
  # ===========================================================================
547
566
  # Настройки выполнения (execution)
548
567
  # ===========================================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workflow-ai",
3
- "version": "1.0.54",
3
+ "version": "1.0.56",
4
4
  "description": "AI Agent Workflow Coordinator — kanban-based pipeline for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
package/src/runner.mjs CHANGED
@@ -7,143 +7,6 @@ import crypto from 'crypto';
7
7
  import yaml from './lib/js-yaml.mjs';
8
8
  import { findProjectRoot } from './lib/find-root.mjs';
9
9
 
10
- // ============================================================================
11
- // ProcessTracker — файловый трекинг дочерних процессов.
12
- //
13
- // Проблема: runner spawn → claude CLI → npx → node (MCP-сервер).
14
- // При жёстком завершении runner'а (kill, закрытие терминала) process.on('exit')
15
- // не срабатывает, MCP-серверы остаются сиротами навсегда.
16
- //
17
- // Решение: PID-файл (.workflow/logs/.runner-pids).
18
- // - Перед spawn: записываем снапшот существующих PID'ов в файл
19
- // - При старте runner'а: читаем файл, вычисляем сирот (текущие − снапшот), убиваем
20
- // - Файл переживает крэш/kill — cleanup гарантирован при следующем запуске
21
- // ============================================================================
22
- const PIDFILE_NAME = '.runner-pids';
23
-
24
- function getPidFilePath(projectRoot) {
25
- return path.resolve(projectRoot, '.workflow/logs', PIDFILE_NAME);
26
- }
27
-
28
- function getProcessSnapshot() {
29
- if (process.platform !== 'win32') return new Set();
30
- try {
31
- const output = execSync(
32
- 'wmic process where "name=\'node.exe\' or name=\'python.exe\' or name=\'python3.exe\' or name=\'cmd.exe\'" get ProcessId /FORMAT:LIST',
33
- { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', timeout: 5000 }
34
- );
35
- const pids = new Set();
36
- for (const line of output.split('\n')) {
37
- const match = line.match(/ProcessId=(\d+)/);
38
- if (match) pids.add(parseInt(match[1], 10));
39
- }
40
- return pids;
41
- } catch {
42
- return new Set();
43
- }
44
- }
45
-
46
- /**
47
- * Записывает снапшот + runner PID в файл.
48
- * Формат: JSON { runnerPid, snapshot: [...], timestamp }
49
- */
50
- function writePidFile(pidFilePath, snapshot) {
51
- try {
52
- const dir = path.dirname(pidFilePath);
53
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
54
- fs.writeFileSync(pidFilePath, JSON.stringify({
55
- runnerPid: process.pid,
56
- snapshot: [...snapshot],
57
- timestamp: new Date().toISOString()
58
- }));
59
- } catch { /* non-critical */ }
60
- }
61
-
62
- /**
63
- * Читает PID-файл. Возвращает { runnerPid, snapshot } или null.
64
- */
65
- function readPidFile(pidFilePath) {
66
- try {
67
- if (!fs.existsSync(pidFilePath)) return null;
68
- const data = JSON.parse(fs.readFileSync(pidFilePath, 'utf-8'));
69
- return {
70
- runnerPid: data.runnerPid,
71
- snapshot: new Set(data.snapshot || [])
72
- };
73
- } catch {
74
- return null;
75
- }
76
- }
77
-
78
- function removePidFile(pidFilePath) {
79
- try { fs.unlinkSync(pidFilePath); } catch { /* ok */ }
80
- }
81
-
82
- /**
83
- * Cleanup сирот от предыдущего запуска.
84
- * Читает PID-файл, берёт текущие процессы, убивает разницу (текущие − снапшот).
85
- * Пропускает собственный PID runner'а.
86
- */
87
- function cleanupOrphansFromFile(pidFilePath, logger = null) {
88
- const saved = readPidFile(pidFilePath);
89
- if (!saved) return;
90
-
91
- // Проверяем что предыдущий runner мёртв (иначе не трогаем — он ещё работает)
92
- if (saved.runnerPid && isProcessAlive(saved.runnerPid)) {
93
- return;
94
- }
95
-
96
- const currentPids = getProcessSnapshot();
97
- const orphans = [];
98
- for (const pid of currentPids) {
99
- if (!saved.snapshot.has(pid) && pid !== process.pid) {
100
- orphans.push(pid);
101
- }
102
- }
103
-
104
- if (orphans.length > 0) {
105
- killPids(orphans, logger);
106
- }
107
-
108
- removePidFile(pidFilePath);
109
- }
110
-
111
- function isProcessAlive(pid) {
112
- try {
113
- process.kill(pid, 0);
114
- return true;
115
- } catch {
116
- return false;
117
- }
118
- }
119
-
120
- function killPids(pids, logger = null) {
121
- if (pids.length === 0) return;
122
- if (process.platform === 'win32') {
123
- for (const pid of pids) {
124
- try {
125
- execSync(`taskkill /pid ${pid} /F`, { stdio: 'pipe' });
126
- } catch { /* already dead */ }
127
- }
128
- } else {
129
- for (const pid of pids) {
130
- try { process.kill(pid, 'SIGKILL'); } catch { /* already dead */ }
131
- }
132
- }
133
- if (logger) {
134
- logger.info(`Cleaned up ${pids.length} orphan process(es): ${pids.join(', ')}`, 'ProcessCleanup');
135
- }
136
- }
137
-
138
- function killChildProcess(child) {
139
- if (!child.pid) return;
140
- if (process.platform === 'win32') {
141
- try { execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: 'pipe' }); } catch {}
142
- } else {
143
- try { child.kill('SIGTERM'); } catch {}
144
- }
145
- }
146
-
147
10
  // ============================================================================
148
11
  // Logger — система логирования с уровнями DEBUG/INFO/WARN/ERROR
149
12
  // ============================================================================
@@ -899,36 +762,21 @@ class StageExecutor {
899
762
  this.promptBuilder = new PromptBuilder(context, counters, previousResults);
900
763
  this.resultParser = new ResultParser();
901
764
 
902
- // Трекинг активных дочерних процессов для cleanup
903
- this.activeChildren = new Set();
904
- // Снапшот PID'ов перед spawn — для вычисления порождённых процессов
905
- this.preSpawnSnapshot = new Set();
765
+ // Текущий дочерний процесс агента (для kill при shutdown)
766
+ this.currentChild = null;
906
767
  }
907
768
 
908
769
  /**
909
- * Убивает все активные дочерние процессы и порождённых ими сирот.
910
- * Snapshot-based: сравнивает текущие PID'ы с снапшотом до spawn,
911
- * новые процессы = порождённые нашим агентом (включая внуков/MCP-серверов).
770
+ * Убивает текущий дочерний процесс агента
912
771
  */
913
- killAllChildren() {
914
- // Убиваем прямых детей через taskkill /T (best effort)
915
- for (const child of this.activeChildren) {
916
- killChildProcess(child);
917
- }
918
- this.activeChildren.clear();
919
-
920
- // Snapshot-based: убиваем все процессы, появившиеся после spawn
921
- if (this.preSpawnSnapshot.size > 0) {
922
- const currentPids = getProcessSnapshot();
923
- const orphans = [];
924
- for (const pid of currentPids) {
925
- if (!this.preSpawnSnapshot.has(pid) && pid !== process.pid) {
926
- orphans.push(pid);
927
- }
928
- }
929
- killPids(orphans, this.logger);
772
+ killCurrentChild() {
773
+ const child = this.currentChild;
774
+ if (!child || !child.pid) return;
775
+ if (process.platform === 'win32') {
776
+ try { execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: 'pipe' }); } catch {}
777
+ } else {
778
+ try { child.kill('SIGTERM'); } catch {}
930
779
  }
931
- this.preSpawnSnapshot = new Set();
932
780
  }
933
781
 
934
782
  /**
@@ -948,6 +796,7 @@ class StageExecutor {
948
796
  // 3. stage.agent — явно указанный агент stage
949
797
  // 4. default_agent — глобальный дефолт
950
798
  let agentId = stage.agent || this.pipeline.default_agent;
799
+ let fallbackModelId = stage.fallback_agent; // fallback_model из agent_by_type имеет приоритет
951
800
  const attempt = (stage.counter && this.counters[stage.counter]) || 0;
952
801
 
953
802
  // Фоллбэк: если task_type не задан, вычисляем из префикса ticket_id (PMA-005 → pma)
@@ -957,8 +806,17 @@ class StageExecutor {
957
806
 
958
807
  if (attempt <= 1 && stage.agent_by_type && taskType) {
959
808
  // Первая попытка: выбор по типу задачи
960
- if (stage.agent_by_type[taskType]) {
961
- agentId = stage.agent_by_type[taskType];
809
+ const agentConfig = stage.agent_by_type[taskType];
810
+ if (agentConfig) {
811
+ // Поддержка формата: { agent: string, fallback_model: string } или просто string
812
+ if (typeof agentConfig === 'object' && agentConfig.agent) {
813
+ agentId = agentConfig.agent;
814
+ if (agentConfig.fallback_model) {
815
+ fallbackModelId = agentConfig.fallback_model;
816
+ }
817
+ } else {
818
+ agentId = agentConfig;
819
+ }
962
820
  if (this.logger) {
963
821
  this.logger.info(`Agent by type: task_type="${taskType}" → ${agentId}`, stageId);
964
822
  }
@@ -996,8 +854,8 @@ class StageExecutor {
996
854
  this.fileGuard.takeSnapshot();
997
855
  }
998
856
 
999
- // Вызываем CLI-агента с поддержкой fallback
1000
- const result = await this.callAgentWithFallback(agent, prompt, stageId, stage.skill, stage.fallback_agent);
857
+ // Вызываем CLI-агента с поддержкой fallback (приоритет: fallback_model из agent_by_type > stage.fallback_agent)
858
+ const result = await this.callAgentWithFallback(agent, prompt, stageId, stage.skill, fallbackModelId);
1001
859
 
1002
860
  // Логгируем завершение stage
1003
861
  if (this.logger) {
@@ -1066,19 +924,12 @@ class StageExecutor {
1066
924
  }
1067
925
  }
1068
926
 
1069
- // Снапшот PID'ов до spawn — для snapshot-based cleanup сирот
1070
- if (this.preSpawnSnapshot.size === 0) {
1071
- this.preSpawnSnapshot = getProcessSnapshot();
1072
- }
1073
-
1074
927
  const child = spawn(agent.command, args, {
1075
928
  cwd: path.resolve(this.projectRoot, agent.workdir || '.'),
1076
929
  stdio: ['pipe', 'pipe', 'pipe'],
1077
930
  shell: useShell
1078
931
  });
1079
-
1080
- // Регистрируем дочерний процесс для cleanup
1081
- this.activeChildren.add(child);
932
+ this.currentChild = child;
1082
933
 
1083
934
  // Передаём промпт через stdin или закрываем если не нужно
1084
935
  if (useStdin) {
@@ -1092,10 +943,15 @@ class StageExecutor {
1092
943
  let stderr = '';
1093
944
  let timedOut = false;
1094
945
 
1095
- // Таймаут — убиваем child + snapshot-based cleanup через killAllChildren
946
+ // Таймаут
1096
947
  const timeoutId = setTimeout(() => {
1097
948
  timedOut = true;
1098
- this.killAllChildren(); // убьёт child + сирот по snapshot
949
+ // На Windows SIGTERM игнорируется используем taskkill /T /F для убийства дерева
950
+ if (process.platform === 'win32' && child.pid) {
951
+ try { execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: 'pipe' }); } catch {}
952
+ } else {
953
+ child.kill('SIGTERM');
954
+ }
1099
955
  if (this.logger) {
1100
956
  this.logger.timeout(stageId, timeout);
1101
957
  }
@@ -1144,8 +1000,8 @@ class StageExecutor {
1144
1000
  });
1145
1001
 
1146
1002
  child.on('close', (code) => {
1003
+ this.currentChild = null;
1147
1004
  clearTimeout(timeoutId);
1148
- this.activeChildren.delete(child);
1149
1005
  // Обрабатываем остаток буфера стриминга
1150
1006
  if (stdoutBuffer.trim()) {
1151
1007
  try {
@@ -1217,7 +1073,6 @@ class StageExecutor {
1217
1073
 
1218
1074
  child.on('error', (err) => {
1219
1075
  clearTimeout(timeoutId);
1220
- this.activeChildren.delete(child);
1221
1076
  if (!timedOut) {
1222
1077
  if (this.logger) {
1223
1078
  this.logger.error(`CLI error: ${err.message}`, stageId);
@@ -1333,13 +1188,7 @@ class PipelineRunner {
1333
1188
  const trustedAgents = this.pipeline.trusted_agents || [];
1334
1189
  this.fileGuard = new FileGuard(protectedPatterns, projectRoot, trustedAgents);
1335
1190
  this.projectRoot = projectRoot;
1336
-
1337
- // Текущий executor для доступа к activeChildren при shutdown
1338
1191
  this.currentExecutor = null;
1339
- // Путь к PID-файлу для cleanup сирот между запусками
1340
- this.pidFilePath = getPidFilePath(projectRoot);
1341
- // Снапшот PID'ов перед стартом пайплайна — для snapshot-based cleanup
1342
- this.preRunSnapshot = new Set();
1343
1192
 
1344
1193
  // Настройка graceful shutdown
1345
1194
  this.setupGracefulShutdown();
@@ -1406,13 +1255,6 @@ class PipelineRunner {
1406
1255
  const maxSteps = this.pipeline.execution?.max_steps || 100;
1407
1256
  const delayBetweenStages = this.pipeline.execution?.delay_between_stages || 5;
1408
1257
 
1409
- // Cleanup сирот от предыдущего запуска (если runner был убит жёстко)
1410
- cleanupOrphansFromFile(this.pidFilePath, this.logger);
1411
-
1412
- // Снапшот процессов до старта + запись PID-файла
1413
- this.preRunSnapshot = getProcessSnapshot();
1414
- writePidFile(this.pidFilePath, this.preRunSnapshot);
1415
-
1416
1258
  this.logger.info('=== Pipeline Runner Started ===', 'PipelineRunner');
1417
1259
  this.logger.info(`Entry stage: ${this.pipeline.entry}`, 'PipelineRunner');
1418
1260
  this.logger.info(`Max steps: ${maxSteps}`, 'PipelineRunner');
@@ -1499,9 +1341,6 @@ class PipelineRunner {
1499
1341
  // Записываем итоговый summary
1500
1342
  this.logger.writeSummary();
1501
1343
 
1502
- // Cleanup: убиваем процессы-сироты, порождённые агентами во время пайплайна
1503
- this._cleanupOrphans();
1504
-
1505
1344
  return {
1506
1345
  steps: this.stepCount,
1507
1346
  tasksExecuted: this.tasksExecuted,
@@ -1628,61 +1467,20 @@ class PipelineRunner {
1628
1467
  /**
1629
1468
  * Настройка graceful shutdown
1630
1469
  */
1631
- /**
1632
- * Snapshot-based cleanup: убивает процессы, появившиеся после старта пайплайна.
1633
- * Сравнивает текущие PID'ы node/python/cmd с снапшотом до run() —
1634
- * разница = процессы порождённые агентами (включая MCP-серверы-сироты).
1635
- */
1636
- _cleanupOrphans() {
1637
- if (this.preRunSnapshot.size === 0) return;
1638
- const currentPids = getProcessSnapshot();
1639
- const orphans = [];
1640
- for (const pid of currentPids) {
1641
- if (!this.preRunSnapshot.has(pid) && pid !== process.pid) {
1642
- orphans.push(pid);
1643
- }
1644
- }
1645
- if (orphans.length > 0) {
1646
- killPids(orphans, this.logger);
1647
- }
1648
- // PID-файл больше не нужен — cleanup выполнен
1649
- removePidFile(this.pidFilePath);
1650
- }
1651
-
1652
1470
  setupGracefulShutdown() {
1653
1471
  const shutdown = (signal) => {
1654
1472
  if (this.logger) {
1655
- this.logger.info(`Received ${signal}. Killing child processes and shutting down...`, 'PipelineRunner');
1473
+ this.logger.info(`Received ${signal}. Shutting down gracefully...`, 'PipelineRunner');
1656
1474
  }
1657
1475
  this.running = false;
1658
- // Убиваем активные дочерние процессы текущего stage
1476
+ // Убиваем текущего агента
1659
1477
  if (this.currentExecutor) {
1660
- this.currentExecutor.killAllChildren();
1478
+ this.currentExecutor.killCurrentChild();
1661
1479
  }
1662
- // Snapshot-based: убиваем всех сирот от всех stage'ей
1663
- this._cleanupOrphans();
1664
1480
  };
1665
1481
 
1666
1482
  process.on('SIGINT', () => shutdown('SIGINT'));
1667
1483
  process.on('SIGTERM', () => shutdown('SIGTERM'));
1668
-
1669
- // Финальный синхронный cleanup при выходе — последний шанс убить сирот
1670
- process.on('exit', () => {
1671
- // Убиваем активные child objects (если exit во время работы stage)
1672
- if (this.currentExecutor) {
1673
- for (const child of this.currentExecutor.activeChildren) {
1674
- if (child.pid) {
1675
- if (process.platform === 'win32') {
1676
- try { execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: 'pipe' }); } catch {}
1677
- } else {
1678
- try { child.kill('SIGKILL'); } catch {}
1679
- }
1680
- }
1681
- }
1682
- }
1683
- // Snapshot-based cleanup
1684
- this._cleanupOrphans();
1685
- });
1686
1484
  }
1687
1485
  }
1688
1486