workflow-ai 1.0.9 → 1.0.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workflow-ai",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "AI Agent Workflow Coordinator — kanban-based pipeline for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -1,131 +1,131 @@
1
- #!/usr/bin/env node
2
-
3
- import { initProject } from './init.mjs';
4
- import { runPipeline } from './runner.mjs';
5
- import { readFileSync } from 'node:fs';
6
- import { join, dirname } from 'node:path';
7
- import { fileURLToPath } from 'node:url';
8
-
9
- const __dirname = dirname(fileURLToPath(import.meta.url));
10
- const pkgPath = join(__dirname, '..', 'package.json');
11
-
12
- const HELP_TEXT = `workflow-ai v1.0.0
13
-
14
- Usage:
15
- workflow init [path] [--force] Initialize .workflow/ in target directory
16
- workflow run [options] Run the AI pipeline
17
- workflow help Show this help
18
- workflow version Show version
19
-
20
- Run options:
21
- --plan <plan> Plan ID to execute
22
- --config <path> Config file path
23
- --project <path> Project root (default: auto-detect)
24
- `;
25
-
26
- function showHelp() {
27
- console.log(HELP_TEXT);
28
- }
29
-
30
- function showVersion() {
31
- try {
32
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
33
- console.log(`workflow-ai v${pkg.version}`);
34
- } catch (err) {
35
- console.error('Error reading package.json:', err.message);
36
- process.exit(1);
37
- }
38
- }
39
-
40
- function parseArgs(argv) {
41
- const args = { _: [] };
42
- let i = 0;
43
- while (i < argv.length) {
44
- const arg = argv[i];
45
- if (arg.startsWith('--')) {
46
- const key = arg.slice(2);
47
- if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
48
- args[key] = argv[i + 1];
49
- i += 2;
50
- } else {
51
- args[key] = true;
52
- i += 1;
53
- }
54
- } else {
55
- args._.push(arg);
56
- i += 1;
57
- }
58
- }
59
- return args;
60
- }
61
-
62
- async function runInit(args) {
63
- const targetPath = args._[0] || process.cwd();
64
- const force = args.force === true;
65
- const result = initProject(targetPath, { force });
66
-
67
- if (result.errors && result.errors.length > 0) {
68
- console.error('Errors:', result.errors.join(', '));
69
- process.exit(1);
70
- }
71
-
72
- console.log('✅ Initialization completed:');
73
- result.steps.forEach(step => console.log(` • ${step}`));
74
- if (result.warnings && result.warnings.length > 0) {
75
- console.warn('Warnings:', result.warnings.join(', '));
76
- }
77
- }
78
-
79
- async function runRun(args) {
80
- // Expose wf's node_modules to child ESM scripts via a custom loader
81
- const loaderPath = join(__dirname, 'wf-loader.mjs');
82
- const loaderUrl = `file:///${loaderPath.replace(/\\/g, '/')}`;
83
- process.env.NODE_OPTIONS = process.env.NODE_OPTIONS
84
- ? `${process.env.NODE_OPTIONS} --import ${loaderUrl}`
85
- : `--import ${loaderUrl}`;
86
-
87
- const argv = [];
88
- if (args.plan) {
89
- argv.push('--plan', args.plan);
90
- }
91
- if (args.config) {
92
- argv.push('--config', args.config);
93
- }
94
- if (args.project) {
95
- argv.push('--project', args.project);
96
- }
97
-
98
- await runPipeline(argv);
99
- }
100
-
101
- export function run(argv) {
102
- const args = parseArgs(argv);
103
- const command = args._.shift();
104
-
105
- switch (command) {
106
- case 'init':
107
- runInit(args);
108
- break;
109
- case 'run':
110
- runRun(args);
111
- break;
112
- case 'help':
113
- showHelp();
114
- break;
115
- case 'version':
116
- showVersion();
117
- break;
118
- default:
119
- if (!command) {
120
- showHelp();
121
- } else {
122
- console.error(`Unknown command: ${command}`);
123
- console.error('Run "workflow help" for usage.');
124
- process.exit(1);
125
- }
126
- }
127
- }
128
-
129
- if (import.meta.url === `file://${process.argv[1]}`) {
130
- run(process.argv.slice(2));
1
+ #!/usr/bin/env node
2
+
3
+ import { initProject } from './init.mjs';
4
+ import { runPipeline } from './runner.mjs';
5
+ import { readFileSync } from 'node:fs';
6
+ import { join, dirname } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const pkgPath = join(__dirname, '..', 'package.json');
11
+
12
+ const HELP_TEXT = `workflow-ai v1.0.0
13
+
14
+ Usage:
15
+ workflow init [path] [--force] Initialize .workflow/ in target directory
16
+ workflow run [options] Run the AI pipeline
17
+ workflow help Show this help
18
+ workflow version Show version
19
+
20
+ Run options:
21
+ --plan <plan> Plan ID to execute
22
+ --config <path> Config file path
23
+ --project <path> Project root (default: auto-detect)
24
+ `;
25
+
26
+ function showHelp() {
27
+ console.log(HELP_TEXT);
28
+ }
29
+
30
+ function showVersion() {
31
+ try {
32
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
33
+ console.log(`workflow-ai v${pkg.version}`);
34
+ } catch (err) {
35
+ console.error('Error reading package.json:', err.message);
36
+ process.exit(1);
37
+ }
38
+ }
39
+
40
+ function parseArgs(argv) {
41
+ const args = { _: [] };
42
+ let i = 0;
43
+ while (i < argv.length) {
44
+ const arg = argv[i];
45
+ if (arg.startsWith('--')) {
46
+ const key = arg.slice(2);
47
+ if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
48
+ args[key] = argv[i + 1];
49
+ i += 2;
50
+ } else {
51
+ args[key] = true;
52
+ i += 1;
53
+ }
54
+ } else {
55
+ args._.push(arg);
56
+ i += 1;
57
+ }
58
+ }
59
+ return args;
60
+ }
61
+
62
+ async function runInit(args) {
63
+ const targetPath = args._[0] || process.cwd();
64
+ const force = args.force === true;
65
+ const result = initProject(targetPath, { force });
66
+
67
+ if (result.errors && result.errors.length > 0) {
68
+ console.error('Errors:', result.errors.join(', '));
69
+ process.exit(1);
70
+ }
71
+
72
+ console.log('✅ Initialization completed:');
73
+ result.steps.forEach(step => console.log(` • ${step}`));
74
+ if (result.warnings && result.warnings.length > 0) {
75
+ console.warn('Warnings:', result.warnings.join(', '));
76
+ }
77
+ }
78
+
79
+ async function runRun(args) {
80
+ // Expose wf's node_modules to child ESM scripts via a custom loader
81
+ const loaderPath = join(__dirname, 'wf-loader.mjs');
82
+ const loaderUrl = `file:///${loaderPath.replace(/\\/g, '/')}`;
83
+ process.env.NODE_OPTIONS = process.env.NODE_OPTIONS
84
+ ? `${process.env.NODE_OPTIONS} --import ${loaderUrl}`
85
+ : `--import ${loaderUrl}`;
86
+
87
+ const argv = [];
88
+ if (args.plan) {
89
+ argv.push('--plan', args.plan);
90
+ }
91
+ if (args.config) {
92
+ argv.push('--config', args.config);
93
+ }
94
+ if (args.project) {
95
+ argv.push('--project', args.project);
96
+ }
97
+
98
+ await runPipeline(argv);
99
+ }
100
+
101
+ export function run(argv) {
102
+ const args = parseArgs(argv);
103
+ const command = args._.shift();
104
+
105
+ switch (command) {
106
+ case 'init':
107
+ runInit(args);
108
+ break;
109
+ case 'run':
110
+ runRun(args);
111
+ break;
112
+ case 'help':
113
+ showHelp();
114
+ break;
115
+ case 'version':
116
+ showVersion();
117
+ break;
118
+ default:
119
+ if (!command) {
120
+ showHelp();
121
+ } else {
122
+ console.error(`Unknown command: ${command}`);
123
+ console.error('Run "workflow help" for usage.');
124
+ process.exit(1);
125
+ }
126
+ }
127
+ }
128
+
129
+ if (import.meta.url === `file://${process.argv[1]}`) {
130
+ run(process.argv.slice(2));
131
131
  }
package/src/lib/utils.mjs CHANGED
@@ -96,3 +96,60 @@ export function getPackageRoot() {
96
96
  // result/src/lib → result
97
97
  return path.resolve(__dirname, '../../');
98
98
  }
99
+
100
+ /**
101
+ * Парсит секцию "## Ревью" тикета и возвращает статус последней записи.
102
+ * Поддерживает табличный и текстовый форматы.
103
+ *
104
+ * Табличный формат:
105
+ * | Дата | Статус | Комментарий |
106
+ * |------|--------|-------------|
107
+ * | 2026-03-08 | passed | Всё ок |
108
+ *
109
+ * Текстовый формат:
110
+ * - 2026-03-08: passed - Всё ок
111
+ * - 2026-03-08: failed - Есть замечания
112
+ *
113
+ * @param {string} content - Содержимое тикета (markdown)
114
+ * @returns {string|null} "passed", "failed" или null (если нет ревью)
115
+ */
116
+ export function getLastReviewStatus(content) {
117
+ if (!content) return null;
118
+
119
+ // Находим секцию "## Ревью" — захватываем всё до следующего заголовка ## или конца файла
120
+ const reviewSectionMatch = content.match(/^##\s*Ревью\s*\n([\s\S]*)(?=\n^##\s|$)/m);
121
+ if (!reviewSectionMatch) return null;
122
+
123
+ const reviewSection = reviewSectionMatch[1].trim();
124
+ if (!reviewSection) return null;
125
+
126
+ // Пробуем распарсить табличный формат
127
+ const tableRows = reviewSection.split('\n').filter(line => line.trim().startsWith('|'));
128
+ if (tableRows.length >= 2) {
129
+ // Есть заголовок и разделитель, ищем строки с данными
130
+ const dataRows = tableRows.slice(2).filter(row => {
131
+ const cells = row.split('|').map(c => c.trim()).filter(c => c);
132
+ return cells.length >= 2;
133
+ });
134
+
135
+ if (dataRows.length > 0) {
136
+ const lastRow = dataRows[dataRows.length - 1];
137
+ const cells = lastRow.split('|').map(c => c.trim()).filter(c => c);
138
+ // Статус обычно во второй колонке (после даты), может содержать эмодзи (✅ passed / ❌ failed)
139
+ const statusRaw = cells[1]?.toLowerCase() || '';
140
+ if (statusRaw.includes('passed')) return 'passed';
141
+ if (statusRaw.includes('failed')) return 'failed';
142
+ }
143
+ }
144
+
145
+ // Пробуем распарсить текстовый формат (список)
146
+ const listItems = reviewSection.split('\n').filter(line => line.trim().match(/^[-*]\s/));
147
+ if (listItems.length > 0) {
148
+ const lastItem = listItems[listItems.length - 1].trim();
149
+ // Ищем статус в формате "- дата: passed/failed - комментарий"
150
+ const statusMatch = lastItem.match(/:\s*(passed|failed)\b/i);
151
+ if (statusMatch) return statusMatch[1].toLowerCase();
152
+ }
153
+
154
+ return null;
155
+ }
package/src/runner.mjs CHANGED
@@ -310,6 +310,12 @@ class PromptBuilder {
310
310
  }
311
311
  }
312
312
 
313
+ // Добавляем блок Instructions если поле instructions задано и непустое
314
+ if (stage.instructions && typeof stage.instructions === 'string' && stage.instructions.trim() !== '') {
315
+ parts.push('\n\nInstructions:');
316
+ parts.push(stage.instructions.trim());
317
+ }
318
+
313
319
  return parts.join('\n');
314
320
  }
315
321
 
@@ -718,8 +724,27 @@ class StageExecutor {
718
724
  throw new Error(`Stage not found: ${stageId}`);
719
725
  }
720
726
 
721
- // Выбираем агента: если есть agent_by_attempt и счётчик — ротация по попыткам
727
+ // Выбираем агента по приоритету:
728
+ // 1. agent_by_attempt[counter] — ротация по попыткам
729
+ // 2. agent_by_type[task_type] — выбор по типу задачи
730
+ // 3. stage.agent — явно указанный агент stage
731
+ // 4. default_agent — глобальный дефолт
722
732
  let agentId = stage.agent || this.pipeline.default_agent;
733
+
734
+ // Приоритет 1: agent_by_type (выбор по типу задачи)
735
+ // task_type берётся из контекста (возвращается из pick-next-task)
736
+ if (stage.agent_by_type && this.context.task_type) {
737
+ const taskType = this.context.task_type;
738
+ if (stage.agent_by_type[taskType]) {
739
+ const typeBasedAgent = stage.agent_by_type[taskType];
740
+ if (this.logger) {
741
+ this.logger.info(`Agent by type: task_type="${taskType}" → ${typeBasedAgent}`, stageId);
742
+ }
743
+ agentId = typeBasedAgent;
744
+ }
745
+ }
746
+
747
+ // Приоритет 2: agent_by_attempt (ротация по попыткам) — перекрывает agent_by_type
723
748
  if (stage.agent_by_attempt && stage.counter) {
724
749
  const attempt = this.counters[stage.counter] || 0;
725
750
  if (stage.agent_by_attempt[attempt]) {
@@ -729,6 +754,7 @@ class StageExecutor {
729
754
  }
730
755
  }
731
756
  }
757
+
732
758
  const agent = this.pipeline.agents[agentId];
733
759
  if (!agent) {
734
760
  throw new Error(`Agent not found: ${agentId}`);
@@ -28,7 +28,7 @@
28
28
  import fs from 'fs';
29
29
  import path from 'path';
30
30
  import { findProjectRoot } from '../lib/find-root.mjs';
31
- import { parseFrontmatter, printResult, normalizePlanId, extractPlanId } from '../lib/utils.mjs';
31
+ import { parseFrontmatter, printResult, normalizePlanId, extractPlanId, serializeFrontmatter } from '../lib/utils.mjs';
32
32
 
33
33
  const PROJECT_DIR = findProjectRoot();
34
34
  const WORKFLOW_DIR = path.join(PROJECT_DIR, '.workflow');
@@ -102,6 +102,34 @@ function readTickets(dir) {
102
102
  return tickets;
103
103
  }
104
104
 
105
+ /**
106
+ * Перемещает тикет из ready/ в backlog/
107
+ */
108
+ function demoteToBacklog(ticketId) {
109
+ const sourcePath = path.join(READY_DIR, `${ticketId}.md`);
110
+ const targetPath = path.join(BACKLOG_DIR, `${ticketId}.md`);
111
+
112
+ if (!fs.existsSync(sourcePath)) {
113
+ console.error(`[WARN] ${ticketId}: not found in ready/, skipping`);
114
+ return false;
115
+ }
116
+
117
+ const content = fs.readFileSync(sourcePath, 'utf8');
118
+ const { frontmatter, body } = parseFrontmatter(content);
119
+
120
+ frontmatter.updated_at = new Date().toISOString();
121
+
122
+ const newContent = serializeFrontmatter(frontmatter) + body;
123
+
124
+ if (!fs.existsSync(BACKLOG_DIR)) {
125
+ fs.mkdirSync(BACKLOG_DIR, { recursive: true });
126
+ }
127
+
128
+ fs.renameSync(sourcePath, targetPath);
129
+ fs.writeFileSync(targetPath, newContent, 'utf8');
130
+ return true;
131
+ }
132
+
105
133
  /**
106
134
  * Проверяет все тикеты в backlog/ и возвращает список готовых
107
135
  */
@@ -137,6 +165,36 @@ function checkBacklog(planId) {
137
165
  return { ready, waiting, total: tickets.length };
138
166
  }
139
167
 
168
+ /**
169
+ * Проверяет тикеты в ready/ и возвращает тикеты в backlog при невыполненных условиях
170
+ */
171
+ function checkReady(planId) {
172
+ const allTickets = readTickets(READY_DIR);
173
+ const tickets = planId
174
+ ? allTickets.filter(t => normalizePlanId(t.frontmatter.parent_plan) === planId)
175
+ : allTickets;
176
+
177
+ const demoted = [];
178
+
179
+ for (const ticket of tickets) {
180
+ const { frontmatter, id } = ticket;
181
+ const conditions = frontmatter.conditions || [];
182
+ const dependencies = frontmatter.dependencies || [];
183
+
184
+ const depsMet = checkDependencies(dependencies);
185
+ const conditionsMet = conditions.every(checkCondition);
186
+
187
+ if (!depsMet || !conditionsMet) {
188
+ if (demoteToBacklog(id)) {
189
+ console.log(`[INFO] ${id}: ready/ → backlog/ (условия не выполнены)`);
190
+ demoted.push(id);
191
+ }
192
+ }
193
+ }
194
+
195
+ return { demoted, total: tickets.length };
196
+ }
197
+
140
198
  async function main() {
141
199
  const planId = extractPlanId();
142
200
 
@@ -144,6 +202,13 @@ async function main() {
144
202
  console.log(`[INFO] Filtering by plan_id: ${planId}`);
145
203
  }
146
204
 
205
+ // Сначала демотирование невалидных тикетов из ready/
206
+ console.log(`[INFO] Checking ready/ for invalid tickets: ${READY_DIR}`);
207
+ const { demoted, total: readyTotal } = checkReady(planId);
208
+ console.log(`[INFO] Total in ready/${planId ? ` (plan ${planId})` : ''}: ${readyTotal}`);
209
+ console.log(`[INFO] Demoted to backlog: ${demoted.length}`);
210
+
211
+ // Затем проверка backlog — демотированные тикеты сразу переоцениваются
147
212
  console.log(`[INFO] Scanning backlog/: ${BACKLOG_DIR}`);
148
213
 
149
214
  const { ready, waiting, total } = checkBacklog(planId);
@@ -160,7 +225,7 @@ async function main() {
160
225
  }
161
226
 
162
227
  if (ready.length > 0) {
163
- printResult({ status: 'has_ready', ready_tickets: ready.join(', ') });
228
+ printResult({ status: 'has_ready', ready_tickets: ready.join(', '), demoted_tickets: demoted.join(', ') });
164
229
  return;
165
230
  }
166
231
 
@@ -168,10 +233,10 @@ async function main() {
168
233
  const readyDirTickets = readTickets(READY_DIR);
169
234
  if (readyDirTickets.length > 0) {
170
235
  console.log(`[INFO] No new ready tickets, but ready/ has ${readyDirTickets.length} ticket(s)`);
171
- printResult({ status: 'default', ready_tickets: '' });
236
+ printResult({ status: 'default', ready_tickets: '', demoted_tickets: demoted.join(', ') });
172
237
  } else {
173
238
  console.log('[INFO] No ready tickets and ready/ is empty');
174
- printResult({ status: 'empty', ready_tickets: '' });
239
+ printResult({ status: 'empty', ready_tickets: '', demoted_tickets: demoted.join(', ') });
175
240
  }
176
241
  }
177
242
 
@@ -27,12 +27,12 @@ const VALID_STATUSES = ['backlog', 'ready', 'in-progress', 'blocked', 'review',
27
27
 
28
28
  // Таблица допустимых переходов
29
29
  const VALID_TRANSITIONS = {
30
- 'backlog': ['ready'],
31
- 'ready': ['in-progress', 'review'],
30
+ 'backlog': ['ready', 'blocked', 'done'],
31
+ 'ready': ['in-progress', 'review', 'backlog'],
32
32
  'in-progress': ['done', 'blocked', 'review'],
33
33
  'blocked': ['ready'],
34
34
  'review': ['done', 'ready', 'in-progress', 'blocked'],
35
- 'done': []
35
+ 'done': ['ready', 'blocked']
36
36
  };
37
37
 
38
38
  /**