workflow-ai 1.0.54 → 1.0.55

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 |
@@ -543,6 +543,15 @@ pipeline:
543
543
  - ".workflow/plans/**" # Планы — только для чтения
544
544
  - ".workflow/config/**" # Конфигурация
545
545
 
546
+ # ===========================================================================
547
+ # Доверенные агенты (trusted_agents)
548
+ # ===========================================================================
549
+ # Агенты, для которых FileGuard не откатывает изменения в protected_files.
550
+ # Поддерживает glob-паттерны: "script-*" соответствует "script-move" и т.д.
551
+ # ===========================================================================
552
+ trusted_agents:
553
+ - "script-check-templates" # Создаёт планы в plans/current/
554
+
546
555
  # ===========================================================================
547
556
  # Настройки выполнения (execution)
548
557
  # ===========================================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workflow-ai",
3
- "version": "1.0.54",
3
+ "version": "1.0.55",
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
  /**
@@ -1066,19 +914,12 @@ class StageExecutor {
1066
914
  }
1067
915
  }
1068
916
 
1069
- // Снапшот PID'ов до spawn — для snapshot-based cleanup сирот
1070
- if (this.preSpawnSnapshot.size === 0) {
1071
- this.preSpawnSnapshot = getProcessSnapshot();
1072
- }
1073
-
1074
917
  const child = spawn(agent.command, args, {
1075
918
  cwd: path.resolve(this.projectRoot, agent.workdir || '.'),
1076
919
  stdio: ['pipe', 'pipe', 'pipe'],
1077
920
  shell: useShell
1078
921
  });
1079
-
1080
- // Регистрируем дочерний процесс для cleanup
1081
- this.activeChildren.add(child);
922
+ this.currentChild = child;
1082
923
 
1083
924
  // Передаём промпт через stdin или закрываем если не нужно
1084
925
  if (useStdin) {
@@ -1092,10 +933,15 @@ class StageExecutor {
1092
933
  let stderr = '';
1093
934
  let timedOut = false;
1094
935
 
1095
- // Таймаут — убиваем child + snapshot-based cleanup через killAllChildren
936
+ // Таймаут
1096
937
  const timeoutId = setTimeout(() => {
1097
938
  timedOut = true;
1098
- this.killAllChildren(); // убьёт child + сирот по snapshot
939
+ // На Windows SIGTERM игнорируется используем taskkill /T /F для убийства дерева
940
+ if (process.platform === 'win32' && child.pid) {
941
+ try { execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: 'pipe' }); } catch {}
942
+ } else {
943
+ child.kill('SIGTERM');
944
+ }
1099
945
  if (this.logger) {
1100
946
  this.logger.timeout(stageId, timeout);
1101
947
  }
@@ -1144,8 +990,8 @@ class StageExecutor {
1144
990
  });
1145
991
 
1146
992
  child.on('close', (code) => {
993
+ this.currentChild = null;
1147
994
  clearTimeout(timeoutId);
1148
- this.activeChildren.delete(child);
1149
995
  // Обрабатываем остаток буфера стриминга
1150
996
  if (stdoutBuffer.trim()) {
1151
997
  try {
@@ -1217,7 +1063,6 @@ class StageExecutor {
1217
1063
 
1218
1064
  child.on('error', (err) => {
1219
1065
  clearTimeout(timeoutId);
1220
- this.activeChildren.delete(child);
1221
1066
  if (!timedOut) {
1222
1067
  if (this.logger) {
1223
1068
  this.logger.error(`CLI error: ${err.message}`, stageId);
@@ -1333,13 +1178,7 @@ class PipelineRunner {
1333
1178
  const trustedAgents = this.pipeline.trusted_agents || [];
1334
1179
  this.fileGuard = new FileGuard(protectedPatterns, projectRoot, trustedAgents);
1335
1180
  this.projectRoot = projectRoot;
1336
-
1337
- // Текущий executor для доступа к activeChildren при shutdown
1338
1181
  this.currentExecutor = null;
1339
- // Путь к PID-файлу для cleanup сирот между запусками
1340
- this.pidFilePath = getPidFilePath(projectRoot);
1341
- // Снапшот PID'ов перед стартом пайплайна — для snapshot-based cleanup
1342
- this.preRunSnapshot = new Set();
1343
1182
 
1344
1183
  // Настройка graceful shutdown
1345
1184
  this.setupGracefulShutdown();
@@ -1406,13 +1245,6 @@ class PipelineRunner {
1406
1245
  const maxSteps = this.pipeline.execution?.max_steps || 100;
1407
1246
  const delayBetweenStages = this.pipeline.execution?.delay_between_stages || 5;
1408
1247
 
1409
- // Cleanup сирот от предыдущего запуска (если runner был убит жёстко)
1410
- cleanupOrphansFromFile(this.pidFilePath, this.logger);
1411
-
1412
- // Снапшот процессов до старта + запись PID-файла
1413
- this.preRunSnapshot = getProcessSnapshot();
1414
- writePidFile(this.pidFilePath, this.preRunSnapshot);
1415
-
1416
1248
  this.logger.info('=== Pipeline Runner Started ===', 'PipelineRunner');
1417
1249
  this.logger.info(`Entry stage: ${this.pipeline.entry}`, 'PipelineRunner');
1418
1250
  this.logger.info(`Max steps: ${maxSteps}`, 'PipelineRunner');
@@ -1499,9 +1331,6 @@ class PipelineRunner {
1499
1331
  // Записываем итоговый summary
1500
1332
  this.logger.writeSummary();
1501
1333
 
1502
- // Cleanup: убиваем процессы-сироты, порождённые агентами во время пайплайна
1503
- this._cleanupOrphans();
1504
-
1505
1334
  return {
1506
1335
  steps: this.stepCount,
1507
1336
  tasksExecuted: this.tasksExecuted,
@@ -1628,61 +1457,20 @@ class PipelineRunner {
1628
1457
  /**
1629
1458
  * Настройка graceful shutdown
1630
1459
  */
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
1460
  setupGracefulShutdown() {
1653
1461
  const shutdown = (signal) => {
1654
1462
  if (this.logger) {
1655
- this.logger.info(`Received ${signal}. Killing child processes and shutting down...`, 'PipelineRunner');
1463
+ this.logger.info(`Received ${signal}. Shutting down gracefully...`, 'PipelineRunner');
1656
1464
  }
1657
1465
  this.running = false;
1658
- // Убиваем активные дочерние процессы текущего stage
1466
+ // Убиваем текущего агента
1659
1467
  if (this.currentExecutor) {
1660
- this.currentExecutor.killAllChildren();
1468
+ this.currentExecutor.killCurrentChild();
1661
1469
  }
1662
- // Snapshot-based: убиваем всех сирот от всех stage'ей
1663
- this._cleanupOrphans();
1664
1470
  };
1665
1471
 
1666
1472
  process.on('SIGINT', () => shutdown('SIGINT'));
1667
1473
  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
1474
  }
1687
1475
  }
1688
1476